ποΈGitΠ―ΡΠ°ποΈ
Commit 19bb682c9c028960a13a10d2ec40ff819d64a6cf
Parents : 65a1f5c
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-17T16:43:07Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-17T16:43:07Z
feat(firmware): add nRF52/RP2040 factory erase and OTAFIX bootloader upgrade over USB (#6526)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Changes
48 files changed, 4077 insertions(+), 116 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 86d5532a05..58cde04ca0 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -639,6 +639,23 @@ firmware
firmware_edition
firmware_event_ended_banner
firmware_event_ended_button
+firmware_maintenance_cdc_unblock_failed
+firmware_maintenance_copy_failed
+firmware_maintenance_erase_action
+firmware_maintenance_erase_confirm_button
+firmware_maintenance_erase_confirm_text
+firmware_maintenance_erase_confirm_title
+firmware_maintenance_no_release
+firmware_maintenance_not_a_bootloader_volume
+firmware_maintenance_select_drive
+firmware_maintenance_softdevice_conflict
+firmware_maintenance_unknown_board
+firmware_maintenance_unknown_softdevice
+firmware_maintenance_unsupported_device
+firmware_maintenance_upgrade_bootloader_action
+firmware_maintenance_upgrade_confirm_text
+firmware_maintenance_upgrade_confirm_title
+firmware_maintenance_wrong_destination
firmware_old
firmware_recovery_banner
firmware_recovery_ble_failed
@@ -712,6 +729,7 @@ firmware_update_stable
firmware_update_starting_dfu
firmware_update_starting_ota
firmware_update_success
+firmware_update_success_wiped
firmware_update_taking_a_while
firmware_update_target
firmware_update_title
@@ -730,6 +748,10 @@ firmware_update_verification_failed
firmware_update_verifying
firmware_update_waiting_reboot
firmware_update_waiting_reconnect
+firmware_update_wipe_ble_detail
+firmware_update_wipe_confirm_warning
+firmware_update_wipe_label
+firmware_update_wipe_usb_detail
firmware_version
fixed_pin
fixed_position
diff --git a/androidApp/src/main/assets/device_bootloader_ota_quirks.json b/androidApp/src/main/assets/device_bootloader_ota_quirks.json
index 960c631010..dca2060a32 100644
--- a/androidApp/src/main/assets/device_bootloader_ota_quirks.json
+++ b/androidApp/src/main/assets/device_bootloader_ota_quirks.json
@@ -18,5 +18,255 @@
"requiresBootloaderUpgradeForOta": true,
"infoUrl": "https://meshtastic.org/docs/getting-started/flashing-firmware/nrf52/update-nrf52-bootloader/"
}
+ ],
+ "softDeviceVariants": [
+ {
+ "hwModel": 7,
+ "hwModelSlug": "T_ECHO",
+ "platformioTargets": [
+ "t-echo"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 9,
+ "hwModelSlug": "RAK4631",
+ "platformioTargets": [
+ "rak4631"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 18,
+ "hwModelSlug": "NANO_G2_ULTRA",
+ "platformioTargets": [
+ "nano-g2-ultra"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 21,
+ "hwModelSlug": "WIO_WM1110",
+ "platformioTargets": [
+ "wio-tracker-wm1110"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 22,
+ "hwModelSlug": "WISMESH_HUB",
+ "platformioTargets": [
+ "rak2560"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 29,
+ "hwModelSlug": "CANARYONE",
+ "platformioTargets": [
+ "canaryone"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 33,
+ "hwModelSlug": "T_ECHO_PLUS",
+ "platformioTargets": [
+ "t-echo-plus"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 63,
+ "hwModelSlug": "NRF52_PROMICRO_DIY",
+ "platformioTargets": [
+ "nrf52_promicro_diy_tcxo"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 69,
+ "hwModelSlug": "HELTEC_MESH_NODE_T114",
+ "platformioTargets": [
+ "heltec-mesh-node-t114"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 71,
+ "hwModelSlug": "TRACKER_T1000_E",
+ "platformioTargets": [
+ "tracker-t1000-e"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 84,
+ "hwModelSlug": "WISMESH_TAP",
+ "platformioTargets": [
+ "rak_wismeshtap"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 88,
+ "hwModelSlug": "XIAO_NRF52_KIT",
+ "platformioTargets": [
+ "seeed_xiao_nrf52840_kit"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 89,
+ "hwModelSlug": "THINKNODE_M1",
+ "platformioTargets": [
+ "thinknode_m1"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 93,
+ "hwModelSlug": "MUZI_BASE",
+ "platformioTargets": [
+ "muzi-base"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 94,
+ "hwModelSlug": "HELTEC_MESH_POCKET",
+ "platformioTargets": [
+ "heltec-mesh-pocket-10000",
+ "heltec-mesh-pocket-5000"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 95,
+ "hwModelSlug": "SEEED_SOLAR_NODE",
+ "platformioTargets": [
+ "seeed_solar_node"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 96,
+ "hwModelSlug": "NOMADSTAR_METEOR_PRO",
+ "platformioTargets": [
+ "rak4631_nomadstar_meteor_pro"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 99,
+ "hwModelSlug": "SEEED_WIO_TRACKER_L1",
+ "platformioTargets": [
+ "seeed_wio_tracker_L1"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 100,
+ "hwModelSlug": "SEEED_WIO_TRACKER_L1_EINK",
+ "platformioTargets": [
+ "seeed_wio_tracker_L1_eink"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 101,
+ "hwModelSlug": "MUZI_R1_NEO",
+ "platformioTargets": [
+ "r1-neo"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 105,
+ "hwModelSlug": "WISMESH_TAG",
+ "platformioTargets": [
+ "rak_wismeshtag"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 108,
+ "hwModelSlug": "HELTEC_MESH_SOLAR",
+ "platformioTargets": [
+ "heltec-mesh-solar"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 109,
+ "hwModelSlug": "T_ECHO_LITE",
+ "platformioTargets": [
+ "t-echo-lite"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 115,
+ "hwModelSlug": "THINKNODE_M3",
+ "platformioTargets": [
+ "thinknode_m3"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 117,
+ "hwModelSlug": "RAK3401",
+ "platformioTargets": [
+ "rak3401-1watt"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 119,
+ "hwModelSlug": "THINKNODE_M4",
+ "platformioTargets": [
+ "thinknode_m4"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 120,
+ "hwModelSlug": "THINKNODE_M6",
+ "platformioTargets": [
+ "thinknode_m6"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 127,
+ "hwModelSlug": "HELTEC_MESH_NODE_T096",
+ "platformioTargets": [
+ "heltec-mesh-node-t096"
+ ],
+ "softDevice": "6.1.1"
+ },
+ {
+ "hwModel": 128,
+ "hwModelSlug": "MESH_TRACKER_X1",
+ "platformioTargets": [
+ "seeed_mesh_tracker_X1"
+ ],
+ "softDevice": "7.3.0"
+ },
+ {
+ "hwModel": 130,
+ "hwModelSlug": "THINKNODE_M8",
+ "platformioTargets": [
+ "thinknode_m8"
+ ]
+ },
+ {
+ "hwModel": 133,
+ "hwModelSlug": "HELTEC_MESH_NODE_T1",
+ "platformioTargets": [
+ "heltec-mesh-node-t1"
+ ],
+ "softDevice": "6.1.1"
+ }
]
-}
\ No newline at end of file
+}
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt
new file mode 100644
index 0000000000..f19f1c8c0d
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/firmware/SoftDeviceQuirkCoverageTest.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.firmware
+
+import kotlinx.serialization.json.Json
+import org.meshtastic.core.model.BootloaderOtaQuirksResponse
+import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+/**
+ * Guards the SoftDevice map in `device_bootloader_ota_quirks.json` against the hardware catalog.
+ *
+ * This test is the reason the map can safely be fail-closed. An unmapped or mistyped row does not break anything at
+ * runtime β it silently makes factory erase unavailable for that device β so nothing else would ever notice. CI has to
+ * be what notices.
+ *
+ * It deliberately does **not** assert which variant each board uses, only that every board has one and that the values
+ * are recognised. The variant itself is verified at runtime against what the device reports on its UF2 drive, and a
+ * disagreement refuses rather than guessing (see `resolveNrfEraseImage`).
+ */
+class SoftDeviceQuirkCoverageTest {
+
+ /**
+ * Models with no firmware variant on `meshtastic/firmware` master, so no ldscript exists to derive a SoftDevice
+ * from. Present in the asset with no `softDevice` value, which makes the refusal deliberate and greppable rather
+ * than an oversight. Remove an entry here once its variant lands upstream.
+ */
+ private val knownUnmapped = setOf(130) // THINKNODE_M8
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ @Test
+ fun `every nrf52840 model in the hardware catalog has a softdevice mapping`() {
+ val nrfModels = nrfHardware().map { it.hwModel }.toSet()
+ val mapped = quirks().softDeviceVariants.filter { it.softDevice != null }.map { it.hwModel }.toSet()
+
+ val missing = nrfModels - mapped - knownUnmapped
+ assertTrue(
+ missing.isEmpty(),
+ "nRF52840 models with no SoftDevice mapping: $missing. Factory erase is silently unavailable for these. " +
+ "Derive each from meshtastic/firmware boards/<board>.json build.arduino.ldscript " +
+ "(nrf52840_s140_v6.ld = 6.1.1, _v7.ld = 7.3.0), or add the hwModel to knownUnmapped with a reason.",
+ )
+ }
+
+ @Test
+ fun `known-unmapped models are present but deliberately carry no softdevice`() {
+ val entries = quirks().softDeviceVariants.associateBy { it.hwModel }
+ knownUnmapped.forEach { hwModel ->
+ val entry = entries[hwModel]
+ assertNotNull(
+ entry,
+ "hwModel $hwModel is listed as known-unmapped but has no row at all β add the row so " +
+ "the refusal is explicit, or drop it from knownUnmapped",
+ )
+ assertEquals(
+ null,
+ entry.softDevice,
+ "hwModel $hwModel is listed as known-unmapped but now carries a SoftDevice β remove it from " +
+ "knownUnmapped instead",
+ )
+ }
+ }
+
+ @Test
+ fun `every softdevice value is one the app ships an erase image for`() {
+ quirks().softDeviceVariants.forEach { entry ->
+ val wire = entry.softDevice ?: return@forEach
+ assertNotNull(
+ SoftDeviceVariant.fromWire(wire),
+ "hwModel ${entry.hwModel} (${entry.hwModelSlug}) declares SoftDevice '$wire', which maps to no " +
+ "erase image. A typo here disables erase for that device silently.",
+ )
+ }
+ }
+
+ @Test
+ fun `every mapped target exists in the hardware catalog`() {
+ // Resolution requires the device-reported target to appear in its row, so a stale target name is a silent
+ // refusal for that build.
+ val catalogTargets = nrfHardware().map { it.platformioTarget }.toSet()
+ quirks().softDeviceVariants.forEach { entry ->
+ entry.platformioTargets.forEach { target ->
+ assertTrue(
+ target in catalogTargets,
+ "hwModel ${entry.hwModel} lists platformioTarget '$target', which is not in the hardware " +
+ "catalog β erase would be refused for any device reporting it",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `every nrf52840 catalog target is covered by its model's row`() {
+ val entries = quirks().softDeviceVariants.associateBy { it.hwModel }
+ nrfHardware().forEach { hw ->
+ val entry = entries[hw.hwModel] ?: return@forEach
+ assertTrue(
+ hw.platformioTargets().any { it in entry.platformioTargets },
+ "hwModel ${hw.hwModel} (${hw.hwModelSlug}) builds target '${hw.platformioTarget}' but its row lists " +
+ "${entry.platformioTargets} β a device reporting that target would be refused",
+ )
+ }
+ }
+
+ @Test
+ fun `mesh tracker x1 is mapped, since the web flasher omits it`() {
+ // hwModel 128 is nrf52840_s140_v7.ld (7.3.0) but is absent from the web flasher's sd7.3 allowlist, so that tool
+ // serves it the 6.1.1 image. Pinned here so the app does not inherit the same gap.
+ val entry = quirks().softDeviceVariants.firstOrNull { it.hwModel == MESH_TRACKER_X1_HW_MODEL }
+ assertNotNull(entry, "MESH_TRACKER_X1 (128) must be mapped")
+ assertEquals(SoftDeviceVariant.S140_7_3_0, SoftDeviceVariant.fromWire(entry.softDevice))
+ }
+
+ private fun NetworkDeviceHardware.platformioTargets(): List<String> = listOf(platformioTarget)
+
+ private fun nrfHardware(): List<NetworkDeviceHardware> =
+ json.decodeFromString<List<NetworkDeviceHardware>>(asset("device_hardware.json").readText()).filter {
+ it.architecture == NRF52840_ARCHITECTURE
+ }
+
+ private fun quirks(): BootloaderOtaQuirksResponse =
+ json.decodeFromString(asset("device_bootloader_ota_quirks.json").readText())
+
+ private fun asset(name: String): File =
+ listOf("src/main/assets/$name", "androidApp/src/main/assets/$name").map(::File).firstOrNull(File::exists)
+ ?: fail("Could not locate $name from working directory ${File(".").absolutePath}")
+
+ private companion object {
+ const val NRF52840_ARCHITECTURE = "nrf52840"
+ const val MESH_TRACKER_X1_HW_MODEL = 128
+ }
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt
new file mode 100644
index 0000000000..0b273d2335
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/state/FirmwareMaintenanceLock.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.common.state
+
+import kotlinx.atomicfu.atomic
+import org.koin.core.annotation.Single
+
+/**
+ * Marks a USB firmware-maintenance sequence as in flight, so the radio transport does not fight it for the device.
+ *
+ * A factory erase leaves the device enumerating as a bare CDC port with no Meshtastic protocol on it. The serial
+ * transport will happily bind to that β its device lookup falls back to whatever probed device it can find, not just
+ * the saved address β and the environmental-recovery listeners re-enter transport startup whenever Bluetooth or the
+ * network flips. Both would claim the port the maintenance flow needs.
+ *
+ * Lives in `:core:common` because the two parties sit in modules that cannot see each other: the flow that takes the
+ * lock is in `:feature:firmware`, and the code that must respect it is in `:core:service`.
+ *
+ * The consequence of ignoring the lock is bounded rather than destructive β a transport that claims the port asserts
+ * DTR itself, which happens to unblock the erase β but it makes the flow's own signal unreliable, and a mesh handshake
+ * against erase firmware is noise nobody needs to debug.
+ */
+@Single
+class FirmwareMaintenanceLock {
+ private val active = atomic(false)
+
+ /** True while a maintenance sequence holds the lock. */
+ val isActive: Boolean
+ get() = active.value
+
+ /** Takes the lock. Idempotent β re-taking an already-held lock is a no-op. */
+ fun acquire() {
+ active.value = true
+ }
+
+ /** Releases the lock. Must run even on failure, so callers use `try`/`finally`. */
+ fun release() {
+ active.value = false
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 56a695ec01..e727715036 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -36,6 +36,8 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.BootloaderOtaQuirksResponse
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
+import org.meshtastic.core.model.SoftDeviceVariantEntry
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
@@ -185,8 +187,43 @@ class DeviceHardwareRepositoryImpl(
/** Resolves entities into a [DeviceHardware] domain model with quirk application. */
private fun resolveHardware(hwModel: Int, entities: List<DeviceHardwareEntity>, target: String?): DeviceHardware? {
val matched = disambiguate(entities, target)
- val quirks = loadQuirks()
- return applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target)
+ val asset = loadQuirksAsset()
+ val withQuirk = applyBootloaderQuirk(hwModel, matched?.asExternalModel(), asset.devices, target)
+ return applySoftDeviceVariant(hwModel, withQuirk, asset.softDeviceVariants, target)
+ }
+
+ /**
+ * Overlays the SoftDevice variant, requiring the reported target to be one this model is actually mapped for.
+ *
+ * Deliberately stricter than [applyBootloaderQuirk] and deliberately not routed through [disambiguate]: both of
+ * those fall back to "close enough" (`firstOrNull()`), which is right for an advisory warning and wrong here.
+ * `hwModel` is not unique β hwModel 94 (`HELTEC_MESH_POCKET`) has two nRF52840 targets β and a plausible-but-wrong
+ * variant writes an erase image into the SoftDevice. Every unresolvable case (asset absent, asset malformed, model
+ * unmapped, reported target not in the row) therefore lands on `null`, and callers must refuse on `null`.
+ */
+ private fun applySoftDeviceVariant(
+ hwModel: Int,
+ base: DeviceHardware?,
+ entries: List<SoftDeviceVariantEntry>,
+ reportedTarget: String?,
+ ): DeviceHardware? = base?.let { hw ->
+ val effectiveTarget = reportedTarget?.takeIf { it.isNotBlank() }
+ val modelEntries = entries.filter { it.hwModel == hwModel }
+ val matched =
+ when {
+ effectiveTarget != null ->
+ modelEntries.firstOrNull { entry ->
+ entry.platformioTargets.any { it.equals(effectiveTarget, ignoreCase = true) }
+ }
+
+ // No reported target: hw.platformioTarget may be disambiguate()'s arbitrary firstOrNull() pick,
+ // so it must not select between rows. Resolve only when every row for this model agrees anyway;
+ // a multi-variant model without a device-reported target stays unresolved (callers refuse on null).
+ modelEntries.map { it.softDevice }.distinct().size == 1 -> modelEntries.first()
+
+ else -> null
+ }
+ hw.copy(softDeviceVariant = SoftDeviceVariant.fromWire(matched?.softDevice))
}
private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? =
@@ -205,14 +242,13 @@ class DeviceHardwareRepositoryImpl(
private fun DeviceHardwareEntity.isStale(): Boolean =
isIncomplete() || (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
- // Quirks are best-effort: swallow any parse/IO error and fall back to "no quirks" rather than failing hardware
- // lookup.
- private fun loadQuirks(): List<BootloaderOtaQuirk> = runCatching {
- assetReader.decode<BootloaderOtaQuirksResponse>("device_bootloader_ota_quirks.json", json)?.devices
- }
- .onFailure { e -> Logger.w(e) { "Failed to load device_bootloader_ota_quirks.json" } }
- .getOrNull()
- .orEmpty()
+ // Quirks are best-effort: swallow any parse/IO error and fall back to an empty asset rather than failing hardware
+ // lookup. Safe for the advisory bootloader warning, and safe for the SoftDevice map too because an empty map
+ // resolves to a null variant, which refuses.
+ private fun loadQuirksAsset(): BootloaderOtaQuirksResponse =
+ runCatching { assetReader.decode<BootloaderOtaQuirksResponse>("device_bootloader_ota_quirks.json", json) }
+ .onFailure { e -> Logger.w(e) { "Failed to load device_bootloader_ota_quirks.json" } }
+ .getOrNull() ?: BootloaderOtaQuirksResponse()
private fun applyBootloaderQuirk(
hwModel: Int,
diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
index 9d9c1af40c..aa61e1a27f 100644
--- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
@@ -37,6 +37,7 @@ import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareNightly
import org.meshtastic.core.model.NetworkFirmwareReleases
+import org.meshtastic.core.model.SoftDeviceVariant
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.repository.DeviceLinkRepository
@@ -46,6 +47,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -73,9 +75,13 @@ class DeviceHardwareRepositoryImplTest {
private class FakeBundledAssetReader(var hardware: List<NetworkDeviceHardware>, private val json: Json) :
BundledAssetReader {
- override fun open(name: String): Source? {
- if (name != "device_hardware.json") return null
- return Buffer().write(json.encodeToString(hardware).encodeToByteArray())
+ /** Raw `device_bootloader_ota_quirks.json` body, or null to model the asset being absent entirely. */
+ var quirksJson: String? = null
+
+ override fun open(name: String): Source? = when (name) {
+ "device_hardware.json" -> Buffer().write(json.encodeToString(hardware).encodeToByteArray())
+ "device_bootloader_ota_quirks.json" -> quirksJson?.let { Buffer().write(it.encodeToByteArray()) }
+ else -> null
}
}
@@ -124,6 +130,7 @@ class DeviceHardwareRepositoryImplTest {
private lateinit var databaseProvider: FakeDatabaseProvider
private lateinit var api: FakeApiService
private lateinit var links: FakeDeviceLinkRepository
+ private lateinit var assetReader: FakeBundledAssetReader
private lateinit var repository: DeviceHardwareRepositoryImpl
@BeforeTest
@@ -131,11 +138,12 @@ class DeviceHardwareRepositoryImplTest {
databaseProvider = FakeDatabaseProvider()
api = FakeApiService(listOf(knownHardware))
links = FakeDeviceLinkRepository()
+ assetReader = FakeBundledAssetReader(listOf(knownHardware), json)
repository =
DeviceHardwareRepositoryImpl(
remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers),
localDataSource = DeviceHardwareLocalDataSource(databaseProvider),
- assetReader = FakeBundledAssetReader(listOf(knownHardware), json),
+ assetReader = assetReader,
json = json,
deviceLinkRepository = links,
dispatchers = dispatchers,
@@ -199,6 +207,145 @@ class DeviceHardwareRepositoryImplTest {
assertEquals(2, api.hardwareCalls)
assertEquals(2, links.reconcileCalls)
}
+
+ // ββ SoftDevice variant resolution: fail-closed on every unresolvable case ββββββββββββββββββββββ
+
+ private val nrfHardware =
+ NetworkDeviceHardware(
+ hwModel = 9,
+ hwModelSlug = "RAK4631",
+ platformioTarget = "rak4631",
+ architecture = "nrf52840",
+ activelySupported = true,
+ displayName = "RAK4631",
+ images = listOf("rak4631.svg"),
+ )
+
+ private fun quirksAsset(hwModel: Int = 9, targets: String = "[\"rak4631\"]", softDevice: String? = "6.1.1") =
+ """
+ {
+ "devices": [],
+ "softDeviceVariants": [
+ { "hwModel": $hwModel, "hwModelSlug": "RAK4631", "platformioTargets": $targets
+ ${softDevice?.let { ", \"softDevice\": \"$it\"" } ?: ""} }
+ ]
+ }
+ """
+ .trimIndent()
+
+ /** Rebuilds the repository around an nRF fixture so SoftDevice resolution can be exercised. */
+ private fun nrfRepository(quirks: String?): DeviceHardwareRepositoryImpl {
+ api = FakeApiService(listOf(nrfHardware))
+ assetReader = FakeBundledAssetReader(listOf(nrfHardware), json).apply { quirksJson = quirks }
+ return DeviceHardwareRepositoryImpl(
+ remoteDataSource = DeviceHardwareRemoteDataSource(api, dispatchers),
+ localDataSource = DeviceHardwareLocalDataSource(databaseProvider),
+ assetReader = assetReader,
+ json = json,
+ deviceLinkRepository = links,
+ dispatchers = dispatchers,
+ )
+ }
+
+ @Test
+ fun softDeviceResolvesWhenModelAndTargetMatch() = runBlocking {
+ val repo = nrfRepository(quirksAsset())
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull()
+
+ assertEquals(SoftDeviceVariant.S140_6_1_1, hardware?.softDeviceVariant)
+ }
+
+ @Test
+ fun softDeviceIsNullWhenTheAssetIsAbsent() = runBlocking {
+ val repo = nrfRepository(quirks = null)
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull()
+
+ assertNotNull(hardware, "hardware lookup must still succeed β only the variant is unavailable")
+ assertEquals(null, hardware.softDeviceVariant, "an absent asset must not resolve a variant")
+ }
+
+ @Test
+ fun softDeviceIsNullWhenTheAssetIsMalformed() = runBlocking {
+ val repo = nrfRepository(quirks = "{ not json at all")
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull()
+
+ assertNotNull(hardware, "a malformed asset must not fail the hardware lookup")
+ assertEquals(null, hardware.softDeviceVariant)
+ }
+
+ @Test
+ fun softDeviceIsNullWhenTheModelIsUnmapped() = runBlocking {
+ val repo = nrfRepository(quirksAsset(hwModel = 999))
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull()
+
+ assertEquals(null, hardware?.softDeviceVariant, "an unmapped model must not borrow another row")
+ }
+
+ @Test
+ fun softDeviceIsNullWhenTheReportedTargetIsNotInTheRow() = runBlocking {
+ // The dangerous case: hwModel matches but the device reports a build we have not verified. Borrowing the row's
+ // variant here is exactly what would write an erase image into a SoftDevice.
+ val repo = nrfRepository(quirksAsset(targets = "[\"rak4631_some_other_build\"]"))
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull()
+
+ assertEquals(null, hardware?.softDeviceVariant, "a target mismatch must refuse, not fall back")
+ }
+
+ /** Two rows for the same hwModel with different SoftDevices β the shape hwModel 94 actually has. */
+ private fun ambiguousQuirksAsset() =
+ """
+ {
+ "devices": [],
+ "softDeviceVariants": [
+ { "hwModel": 9, "hwModelSlug": "RAK4631", "platformioTargets": ["rak4631"], "softDevice": "6.1.1" },
+ { "hwModel": 9, "hwModelSlug": "RAK4631", "platformioTargets": ["rak4631_v2"], "softDevice": "7.3.0" }
+ ]
+ }
+ """
+ .trimIndent()
+
+ @Test
+ fun softDeviceResolvesWithoutAReportedTargetWhenTheModelIsUnambiguous() = runBlocking {
+ val repo = nrfRepository(quirksAsset())
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = null).getOrNull()
+
+ assertEquals(SoftDeviceVariant.S140_6_1_1, hardware?.softDeviceVariant)
+ }
+
+ @Test
+ fun softDeviceIsNullWithoutAReportedTargetWhenTheModelHasMultipleVariants() = runBlocking {
+ // Without a device-reported build target, platformioTarget may be disambiguate()'s arbitrary pick β it
+ // must not choose between rows whose SoftDevices differ.
+ val repo = nrfRepository(ambiguousQuirksAsset())
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = null).getOrNull()
+
+ assertEquals(null, hardware?.softDeviceVariant, "an ambiguous model must not resolve from a guessed target")
+ }
+
+ @Test
+ fun softDeviceTreatsABlankReportedTargetAsAbsent() = runBlocking {
+ val repo = nrfRepository(ambiguousQuirksAsset())
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "").getOrNull()
+
+ assertEquals(null, hardware?.softDeviceVariant)
+ }
+
+ @Test
+ fun softDeviceIsNullWhenTheValueIsUnrecognised() = runBlocking {
+ val repo = nrfRepository(quirksAsset(softDevice = "6.1.2"))
+
+ val hardware = repo.getDeviceHardwareByModel(hwModel = 9, target = "rak4631").getOrNull()
+
+ assertEquals(null, hardware?.softDeviceVariant, "an unknown SoftDevice string must not map to an image")
+ }
}
class DeviceHardwareRefreshGateTest {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
index a8f75e76be..4c4112432d 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt
@@ -72,8 +72,10 @@ fun DeviceHardwareEntity.asExternalModel() = DeviceHardware(
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,
+ // Quirk/overlay fields are applied at read time by DeviceHardwareRepositoryImpl and never persisted.
requiresBootloaderUpgradeForOta = null,
bootloaderInfoUrl = null,
+ softDeviceVariant = null,
supportLevel = supportLevel,
tags = tags,
)
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
index 5bf90acf5b..bcd13e71fd 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/BootloaderOtaQuirk.kt
@@ -20,7 +20,63 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Envelope of the bundled `device_bootloader_ota_quirks.json` asset. */
-@Serializable data class BootloaderOtaQuirksResponse(val devices: List<BootloaderOtaQuirk> = emptyList())
+@Serializable
+data class BootloaderOtaQuirksResponse(
+ val devices: List<BootloaderOtaQuirk> = emptyList(),
+ /**
+ * Per-model SoftDevice variants. Kept as a separate array from [devices] on purpose: [BootloaderOtaQuirk] is an
+ * advisory warning that may safely fail open, while this map gates a destructive flash and must fail closed. One
+ * record carrying both postures would invite a future edit that relaxes the wrong one.
+ */
+ @SerialName("softDeviceVariants") val softDeviceVariants: List<SoftDeviceVariantEntry> = emptyList(),
+)
+
+/**
+ * The Nordic SoftDevice an nRF52840 board's firmware is linked against. Static hardware fact, derived from the board's
+ * `build.arduino.ldscript` in `meshtastic/firmware` (`nrf52840_s140_v6.ld` / `_v7.ld`).
+ *
+ * Load-bearing for factory erase: the erase images are linked for a specific application start address (0x26000 for
+ * 6.1.1, 0x27000 for 7.3.0) and the UF2 bootloader's write guard begins at `MBR_SIZE` (0x1000), so the SoftDevice
+ * region is writable. Flashing the 6.1.1 image to a 7.3.0 device erases the SoftDevice's last page, which needs SWD or
+ * serial DFU to recover. Never infer this value β an unresolved variant must refuse the operation.
+ */
+enum class SoftDeviceVariant {
+ S140_6_1_1,
+ S140_7_3_0,
+ ;
+
+ companion object {
+ /**
+ * Maps the wire value to a variant, returning `null` for anything unrecognized.
+ *
+ * The asset carries a `String?` rather than this enum because the shared `Json` sets `coerceInputValues =
+ * true`, which would silently coerce a typo'd enum value to a default. An unrecognized string lands on `null`
+ * instead, and `null` refuses.
+ */
+ fun fromWire(value: String?): SoftDeviceVariant? = when (value) {
+ "6.1.1" -> S140_6_1_1
+ "7.3.0" -> S140_7_3_0
+ else -> null
+ }
+ }
+}
+
+/**
+ * One hardware model's SoftDevice mapping.
+ *
+ * @property platformioTargets Every catalog target for this [hwModel]. Resolution requires the device-reported target
+ * to appear here, because `hwModel` alone is not unique β hwModel 94 (`HELTEC_MESH_POCKET`) has two nRF52840 targets
+ * β and borrowing a sibling's variant is exactly the mistake that corrupts a SoftDevice.
+ * @property softDevice Wire value (`"6.1.1"` / `"7.3.0"`), or absent when the variant is unknown. An entry present with
+ * no `softDevice` records a deliberate, greppable refusal rather than an oversight.
+ */
+@Serializable
+data class SoftDeviceVariantEntry(
+ @SerialName("hwModel") val hwModel: Int,
+ @SerialName("hwModelSlug") val hwModelSlug: String? = null,
+ @SerialName("platformioTargets") val platformioTargets: List<String> = emptyList(),
+ @SerialName("softDevice") val softDevice: String? = null,
+)
@Serializable
data class BootloaderOtaQuirk(
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
index e51e626210..b17fb6012f 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
@@ -38,10 +38,26 @@ data class DeviceHardware(
val requiresBootloaderUpgradeForOta: Boolean? = null,
/** Optional URL pointing to documentation for upgrading the bootloader. */
val bootloaderInfoUrl: String? = null,
+ /**
+ * The SoftDevice this board's firmware is linked against, or `null` when it could not be resolved. A read-time
+ * overlay like [requiresBootloaderUpgradeForOta] β never persisted.
+ *
+ * `null` means "unknown", never "the common one": destructive flows must refuse rather than assume. See
+ * [SoftDeviceVariant].
+ */
+ val softDeviceVariant: SoftDeviceVariant? = null,
val supportLevel: Int? = null,
val tags: List<String>? = null,
) {
/** Returns true if the device architecture is ESP32-based. */
val isEsp32Arc: Boolean
get() = architecture.startsWith("esp32", ignoreCase = true)
+
+ /** Returns true if the device architecture is nRF52-based. */
+ val isNrf52Arc: Boolean
+ get() = architecture.startsWith("nrf52", ignoreCase = true)
+
+ /** Returns true if the device architecture is RP2040-based. */
+ val isRp2040Arc: Boolean
+ get() = architecture.startsWith("rp2040", ignoreCase = true)
}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
index 1d8f12204d..5b6ef64617 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
@@ -21,9 +21,12 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
+import co.touchlab.kermit.Logger
import com.hoho.android.usbserial.driver.UsbSerialDriver
+import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -35,9 +38,15 @@ import kotlinx.coroutines.withContext
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
+import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.registerReceiverCompat
import org.meshtastic.core.di.CoroutineDispatchers
+/** Any standard rate works for a DTR poke β the erase firmware only observes the line state, never the baud. */
+private const val POKE_BAUD_RATE = 115200
+
+private const val DATA_BITS_8 = 8
+
/** Repository responsible for maintaining and updating the state of USB connectivity. */
@OptIn(ExperimentalCoroutinesApi::class)
@Single
@@ -92,6 +101,40 @@ class UsbRepository(
fun requestPermission(device: UsbDevice): Flow<Boolean> =
usbManagerLazy.value?.requestPermission(application, device) ?: emptyFlow()
+ /** True when the app already holds USB permission for [device], so a request round-trip can be skipped. */
+ fun hasPermission(device: UsbDevice): Boolean = usbManagerLazy.value?.hasPermission(device) == true
+
+ /**
+ * Opens [driver]'s port, asserts DTR/RTS for [holdMillis], and closes it again.
+ *
+ * Exists because some firmware waits for a host before it will run: the nRF52 factory-erase image blocks in `while
+ * (!Serial)` before formatting, and `Serial` only becomes truthy once DTR is asserted. Deliberately does not go
+ * through [createSerialConnection], which welds the assertion to a `SerialInputOutputManager` reader thread and a
+ * mandatory listener β there is no Meshtastic protocol on the other end here, just a blocked `setup()`.
+ *
+ * @return true when the port was opened and DTR held; false if permission is missing or the port could not be
+ * opened.
+ */
+ suspend fun pokeDtr(driver: UsbSerialDriver, holdMillis: Long): Boolean = withContext(dispatchers.io) {
+ val manager = usbManagerLazy.value ?: return@withContext false
+ val port = driver.ports.firstOrNull() ?: return@withContext false
+ val connection = manager.openDevice(driver.device) ?: return@withContext false
+
+ try {
+ port.open(connection)
+ port.setParameters(POKE_BAUD_RATE, DATA_BITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
+ port.dtr = true
+ port.rts = true
+ delay(holdMillis)
+ true
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "DTR poke failed for ${driver.device.usbSerialStableKey()}" }
+ false
+ } finally {
+ ignoreException(silent = true) { port.close() }
+ }
+ }
+
fun refreshState() {
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index e7d8ba8895..5ed60b798b 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -666,6 +666,23 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_event_ended_banner">%1$s has ended. Return to standard Meshtastic firmware to restore normal features.</string>
<string name="firmware_event_ended_button">Update firmware</string>
+ <string name="firmware_maintenance_cdc_unblock_failed">The erase file was copied but the device didn't start erasing. Nothing has been changed yet. Unplug the device, double-press its reset button, and try again.</string>
+ <string name="firmware_maintenance_copy_failed">Couldn't copy the file to the device's drive. Make sure the drive is still connected and try again.</string>
+ <string name="firmware_maintenance_erase_action">Erase and reinstall</string>
+ <string name="firmware_maintenance_erase_confirm_button">Erase device</string>
+ <string name="firmware_maintenance_erase_confirm_text">This erases everything on %1$s, including its channels, keys and all settings, then reinstalls the firmware. It cannot be undone and there is no backup.\n\nYou will be asked to select the device's drive twice: once for the erase file, then again for the firmware.</string>
+ <string name="firmware_maintenance_erase_confirm_title">Erase this device?</string>
+ <string name="firmware_maintenance_no_release">Select a firmware version first, so the device can be reinstalled after erasing.</string>
+ <string name="firmware_maintenance_not_a_bootloader_volume">That drive isn't the device's update drive. Select the drive that appears when the device is in update mode (it contains a file named INFO_UF2.TXT).</string>
+ <string name="firmware_maintenance_select_drive">Select the device's update drive</string>
+ <string name="firmware_maintenance_softdevice_conflict">The device reports a different Bluetooth stack than expected, so erasing it isn't safe. Please report this along with your device model.</string>
+ <string name="firmware_maintenance_unknown_board">This device's bootloader isn't one this app has an upgrade for.</string>
+ <string name="firmware_maintenance_unknown_softdevice">This app can't confirm which Bluetooth stack this device uses, so erasing it isn't safe. Use the web flasher at flasher.meshtastic.org instead.</string>
+ <string name="firmware_maintenance_unsupported_device">This device doesn't support erasing from the app.</string>
+ <string name="firmware_maintenance_upgrade_bootloader_action">Upgrade bootloader</string>
+ <string name="firmware_maintenance_upgrade_confirm_text">This installs an upgraded bootloader on %1$s, which makes future Bluetooth updates much faster. The firmware is reinstalled afterwards.\n\nDon't disconnect the device while this runs.</string>
+ <string name="firmware_maintenance_upgrade_confirm_title">Upgrade bootloader?</string>
+ <string name="firmware_maintenance_wrong_destination">That location isn't the device's update drive, so nothing was written to the device. Select the removable drive that appears when the device is in update mode.</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
<string name="firmware_recovery_ble_failed">Couldn't finish the update over Bluetooth. This device's stock bootloader can't reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor's serial DFU tool (for example adafruit-nrfutil) to recover the device.</string>
@@ -722,7 +739,7 @@
<string name="firmware_update_open_flasher">Open Meshtastic Flasher</string>
<string name="firmware_update_ota_failed">OTA update failed: %1$s</string>
<string name="firmware_update_ota_unsupported_reason">This device's OTA loader rejected the requested update method: %1$s</string>
- <string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, use the vendor's serial DFU tool (for example, adafruit-nrfutil dfu serial with the provided bootloader .zip file). Copying the .uf2 file alone will not update the bootloader.</string>
+ <string name="firmware_update_rak4631_bootloader_hint">For RAK WisBlock RAK4631, the vendor's bootloader .zip has to be flashed with a serial DFU tool such as adafruit-nrfutil β copying that .zip to the device's drive won't work. Alternatively, connect this device over USB and use the bootloader upgrade in this app.</string>
<string name="firmware_update_rebooting">Rebooting to DFU...</string>
<string name="firmware_update_release_notes">Release Notes</string>
<string name="firmware_update_requires_bin">%1$s requires a target-matching .bin file.</string>
@@ -739,6 +756,7 @@
<string name="firmware_update_starting_dfu">Starting DFU...</string>
<string name="firmware_update_starting_ota">Starting OTA update...</string>
<string name="firmware_update_success">Update Successful!</string>
+ <string name="firmware_update_success_wiped">The device was reset to factory defaults and needs to be set up (and paired) again.</string>
<string name="firmware_update_taking_a_while">This might take a minute...</string>
<string name="firmware_update_target">Target: %1$s</string>
<string name="firmware_update_title">Firmware Update</string>
@@ -757,6 +775,10 @@
<string name="firmware_update_verifying">Verifying update...</string>
<string name="firmware_update_waiting_reboot">Waiting for device to reboot into OTA mode...</string>
<string name="firmware_update_waiting_reconnect">Waiting for device to reconnect...</string>
+ <string name="firmware_update_wipe_ble_detail">Factory-resets the device once the update is verified. All settings and Bluetooth pairing are removed.</string>
+ <string name="firmware_update_wipe_confirm_warning">The device will also be erased β all settings, channels, and keys will be lost.</string>
+ <string name="firmware_update_wipe_label">Erase device during update</string>
+ <string name="firmware_update_wipe_usb_detail">Wipes the device's flash completely, then installs the selected firmware from scratch.</string>
<string name="firmware_version">Firmware version: %1$s</string>
<string name="fixed_pin">Fixed PIN</string>
<string name="fixed_position">Fixed Position</string>
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index de41730fdb..b7f27ff5e6 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -60,6 +60,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
+import org.meshtastic.core.common.state.FirmwareMaintenanceLock
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
@@ -171,6 +172,7 @@ class SharedRadioInterfaceService(
private val radioPrefs: RadioPrefs,
private val transportFactory: RadioTransportFactory,
private val analytics: PlatformAnalytics,
+ private val firmwareMaintenanceLock: FirmwareMaintenanceLock,
) : RadioInterfaceService {
override val supportedDeviceTypes: List<DeviceType>
@@ -588,7 +590,7 @@ class SharedRadioInterfaceService(
// explicitly disconnected from. stopTransportLocked() below still fires on
// BLE-disabled to tear down a running BLE link, but we deliberately do NOT
// clear connectionRequested here β that is disconnect()'s job.
- if (connectionRequested) {
+ if (connectionRequested && !firmwareMaintenanceLock.isActive) {
startTransportLocked()
}
} else if (runningTransportId == InterfaceId.BLUETOOTH) {
@@ -604,7 +606,7 @@ class SharedRadioInterfaceService(
transportMutex.withLock {
if (state) {
// Environmental recovery only β see the BLE listener above for rationale.
- if (connectionRequested) {
+ if (connectionRequested && !firmwareMaintenanceLock.isActive) {
startTransportLocked()
}
} else if (runningTransportId == InterfaceId.TCP) {
@@ -644,6 +646,9 @@ class SharedRadioInterfaceService(
.onEach {
transportMutex.withLock {
if (!connectionRequested) return@withLock
+ // A USB firmware-maintenance sequence owns the port: the device is enumerating as bare erase or
+ // bootloader firmware, and binding a mesh transport to it would claim the port the flow needs.
+ if (firmwareMaintenanceLock.isActive) return@withLock
if (runningTransportId != InterfaceId.SERIAL) return@withLock
// Race-defense: the combine snapshot may be stale by the time we acquire
// transportMutex β another path (setDeviceAddress, BLE liveness restart) may
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
index b1a9ea57f7..2d32aac00d 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
@@ -42,6 +42,7 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.common.state.FirmwareMaintenanceLock
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
@@ -151,6 +152,7 @@ class SharedRadioInterfaceServiceLivenessTest {
private val networkRepository: NetworkRepository = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
+ private val firmwareMaintenanceLock = FirmwareMaintenanceLock()
/**
* Minimal [LifecycleOwner] for tests that avoids [LifecycleRegistry], which enforces main-thread checks and throws
@@ -304,6 +306,7 @@ class SharedRadioInterfaceServiceLivenessTest {
radioPrefs = radioPrefs,
transportFactory = transportFactory,
analytics = analytics,
+ firmwareMaintenanceLock = firmwareMaintenanceLock,
)
service.clockMillis = { clock }
// Register the service so tearDown can disconnect it deterministically (the heartbeat loop
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index 878e737dbc..1cc1bcbd04 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -188,6 +188,7 @@ class FakeRadioController :
sentPackets.clear()
favoritedNodes.clear()
sentSharedContacts.clear()
+ factoryResetCalls.clear()
configWrites.clear()
channelWrites.clear()
settingsOperations.clear()
@@ -355,7 +356,12 @@ class FakeRadioController :
override suspend fun shutdown(destNum: Int, packetId: Int) {}
- override suspend fun factoryReset(destNum: Int, packetId: Int) {}
+ /** Destination node numbers of [factoryReset] calls, for tests asserting a reset was (or was not) sent. */
+ val factoryResetCalls = mutableListOf<Int>()
+
+ override suspend fun factoryReset(destNum: Int, packetId: Int) {
+ factoryResetCalls += destNum
+ }
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {}
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 062b54f0ac..d7a3a160bb 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -146,6 +146,15 @@ actual fun rememberSaveFileLauncher(
}
}
+@Composable
+actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit {
+ val launcher =
+ rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
+ onTreeUriSelect(uri?.toKmpUri())
+ }
+ return remember(launcher) { { launcher.launch(null) } }
+}
+
@Composable
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit {
val launcher =
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 560521c3c5..7721bc7b71 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -46,6 +46,16 @@ expect fun rememberSaveFileLauncher(
/** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */
@Composable expect fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit
+/**
+ * Returns a launcher that prompts the user to pick a *directory*. The callback receives a tree URI, or `null` if the
+ * user cancelled.
+ *
+ * Distinct from [rememberSaveFileLauncher] because a tree URI grants access to the picked directory's contents, which a
+ * single-document URI does not. Needed where the app must inspect a volume before writing to it β for example reading a
+ * UF2 bootloader's `INFO_UF2.TXT` to confirm which board it is and that the volume really is a bootloader drive.
+ */
+@Composable expect fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit
+
/**
* Returns a suspend function that reads up to [maxChars] characters of text from a [CommonUri]. Returns `null` if the
* file is empty or cannot be read.
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
index 2f3fe81e84..c14d7399a1 100644
--- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
@@ -46,6 +46,8 @@ actual fun rememberSaveFileLauncher(
@Composable
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> }
+@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit = {}
+
@Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { _, _ -> null }
@Composable actual fun KeepScreenOn(enabled: Boolean) {}
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
index 846a8e2763..984d7ef716 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
@@ -29,6 +29,7 @@ import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.net.URI
+import javax.swing.JFileChooser
/** JVM stub β NFC settings are not available on Desktop. */
@Composable
@@ -88,6 +89,18 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
}
}
+/** JVM β Opens a native dialog to pick a directory. */
+@Composable
+actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit = {
+ // AWT FileDialog cannot select directories portably; JFileChooser can.
+ val chooser = JFileChooser().apply { fileSelectionMode = JFileChooser.DIRECTORIES_ONLY }
+ if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
+ onTreeUriSelect(CommonUri.parse(chooser.selectedFile.toURI().toString()))
+ } else {
+ onTreeUriSelect(null)
+ }
+}
+
/** JVM β Reads text from a file URI. */
@Composable
actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
diff --git a/docs/en/user/firmware.md b/docs/en/user/firmware.md
index c59b7093be..7f6a3f62da 100644
--- a/docs/en/user/firmware.md
+++ b/docs/en/user/firmware.md
@@ -43,7 +43,15 @@ The most common update method for Android users:
When your radio is connected over **USB/serial** (rather than Bluetooth), the Firmware Update screen offers **USB File Transfer**. The app reboots the device into DFU mode, then prompts you to save the `.uf2` file to the device's DFU drive using the system file picker. This option appears only on a USB/serial connection β it is not available over Bluetooth.
-> βΉοΈ **nRF bootloader note:** Some devices (e.g. RAK WisBlock RAK4631) need their bootloader flashed with the vendor's serial DFU tool (such as `adafruit-nrfutil`) β copying the `.uf2` alone won't update the bootloader. The app surfaces a hint when this applies.
+> βΉοΈ **nRF bootloader note:** A vendor bootloader supplied as a `.zip` (e.g. RAK WisBlock RAK4631) has to be flashed with a serial DFU tool such as `adafruit-nrfutil` β copying that `.zip` to the drive won't work. A bootloader supplied as an `update-....uf2` **can** be installed by copying it to the drive; that is how the app's own bootloader upgrade works. The app surfaces a hint when the serial-only route applies.
+
+### Factory Erase and Bootloader Upgrade
+
+On a **USB/serial** connection, nRF52 and RP2040 devices also offer **Erase and reinstall** and, where an upgraded bootloader is published for the board, **Upgrade bootloader**.
+
+Erasing wipes everything on the device β channels, keys and all settings β and there is no backup, so the app asks for confirmation first. Both operations write two files in turn, so you will be asked to select the device's update drive twice: once for the erase or bootloader image, then again for the firmware.
+
+The app reads `INFO_UF2.TXT` from the drive you select to confirm it really is the device's update drive and to identify the board before writing anything. If it can't confirm which Bluetooth stack your device uses it refuses to erase and points you at the [Web Flasher](https://flasher.meshtastic.org) instead β picking wrong there can leave the device needing a hardware programmer to recover.
### Other Flashing Options
diff --git a/feature/firmware/README.md b/feature/firmware/README.md
index f8064904e9..6a0f04b38e 100644
--- a/feature/firmware/README.md
+++ b/feature/firmware/README.md
@@ -76,6 +76,33 @@ sequenceDiagram
USB->>USB: Auto-Flash & Reboot
```
+#### 4. USB Maintenance: Factory Erase & OTAFIX Bootloader Upgrade
+An nRF52/RP2040 device can also run a **factory erase** (wipes the internal filesystem, useful for a device stuck in a bad state or carrying stale event-firmware state) or, on boards OTAFIX ships a bootloader for, a **bootloader self-update**. The erase is reached through the update screen's "Erase device during update" opt-in (default off) rather than a standalone action, so a wipe always ends with the selected release installed; over BLE/WiFi the same opt-in instead sends an admin factory reset once the update is verified. Both USB flows are two-pass sequences: the maintenance image (erase or OTAFIX) is written first, which reboots the device back into a bare bootloader; the release firmware is then written as the second, ordinary UF2 pass.
+
+Two runtime facts make the maintenance image itself safety-critical, not just another UF2 write:
+
+- **The nRF52 erase image is SoftDevice-version-specific.** Writing the S140 6.1.1 image to a 7.3.0 device (or vice versa) corrupts the SoftDevice with no on-device recovery. `MaintenanceUf2.kt` treats the mounted volume's own `INFO_UF2.TXT` `SoftDevice:` line as authoritative over the bundled hardware-catalog hint β the two must agree, or the app refuses rather than guessing (`EraseImageResolution.Conflict`).
+- **OTAFIX bootloaders are resolved by `Board-ID`, not by build target or USB VID/PID** β both of the latter collide across multiple boards. `otafixUf2ForBoardId()` looks up the exact bootloader image for the `Board-ID:` line the volume reports; the Meshtastic build-target name is only ever used to decide whether to *offer* the action in the UI.
+
+```mermaid
+sequenceDiagram
+ participant App as Android App
+ participant Radio as Mesh Node
+ participant USB as USB Mass Storage
+
+ App->>Radio: rebootToDfu()
+ Radio->>Radio: Mounts as UF2 bootloader drive
+ App->>USB: Read INFO_UF2.TXT (Board-ID, SoftDevice)
+ App->>App: Resolve erase/OTAFIX image, verify digest + target address
+ App->>USB: Write maintenance image
+ USB->>USB: Auto-flash & reboot to bare bootloader
+ App->>App: Prompt User to Save release firmware
+ App->>USB: Write firmware.uf2 (pass 2)
+ USB->>USB: Auto-Flash & Reboot
+```
+
+A `FirmwareMaintenanceLock` (`:core:common`) is held for the duration of the sequence so `SharedRadioInterfaceService`'s environmental-recovery listeners don't claim the erase firmware's bare CDC port out from under the flow; it is released when the sequence's terminal pass completes, fails, or the ViewModel is cleared mid-sequence.
+
### Key Classes
- `FirmwareUpdateManager.kt`: Top-level orchestrator for all firmware update flows.
@@ -90,6 +117,9 @@ sequenceDiagram
- `SecureDfuTransport.kt`: BLE transport layer for Secure DFU using Kable (control/data point characteristics, PRN flow control).
- `DfuZipParser.kt`: Parses Nordic DFU ZIP archives (manifest, init packet, firmware binary).
- `UsbUpdateHandler.kt`: Handles USB/UF2 firmware updates across platforms.
+- `MaintenanceUf2.kt`: Pinned erase/OTAFIX image tables, `INFO_UF2.TXT` parsing (Board-ID, SoftDevice), and the drive-vs-map SoftDevice resolution used to pick a safe erase image.
+- `UsbMaintenance.kt`: Pure gating (`usbMaintenanceGate`) and volume-inspection/image-choice types for the factory-erase and bootloader-upgrade actions.
+- `UsbUpdateSupport.kt`: Sequences a maintenance pass (download β reboot to DFU β vet volume β write β confirm landed) and drives the two-pass state machine.
## Dependency Graph
diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts
index e04f185c02..fc0b74e0d4 100644
--- a/feature/firmware/build.gradle.kts
+++ b/feature/firmware/build.gradle.kts
@@ -52,7 +52,13 @@ kotlin {
implementation(libs.markdown.renderer.m3)
}
- androidMain.dependencies { implementation(libs.markdown.renderer.android) }
+ androidMain.dependencies {
+ implementation(libs.markdown.renderer.android)
+ // AndroidFirmwareUsbManager needs UsbSerialDriver to poke DTR on the erase image's CDC port.
+ // :core:network exposes usb-serial as `implementation`, so it is not on our classpath transitively β
+ // feature/connections declares it the same way for the same reason.
+ implementation(libs.usb.serial.android)
+ }
// performUsbUpdate resolves compose-resources strings, whose desktop implementation needs
// the skiko-awt runtime to read the system theme.
diff --git a/feature/firmware/detekt-baseline.xml b/feature/firmware/detekt-baseline.xml
index 71ba09da40..550a63d6a0 100644
--- a/feature/firmware/detekt-baseline.xml
+++ b/feature/firmware/detekt-baseline.xml
@@ -12,7 +12,7 @@
<ID>MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun CheckingState</ID>
<ID>MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun ErrorState</ID>
<ID>MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun VerifyingState</ID>
- <ID>MultipleEmitters:FirmwareUpdateScreen.kt:@Composable private fun AwaitingFileSaveState</ID>
+ <ID>MultipleEmitters:FirmwareUpdateScreen.kt:@Composable internal fun AwaitingFileSaveState</ID>
<ID>MultipleEmitters:FirmwareUpdateScreen.kt:@Composable private fun VerificationFailedState</ID>
<ID>PreviewPublic:FirmwarePreviews.kt:@PreviewLightDark @Composable fun CheckingStatePreview</ID>
<ID>PreviewPublic:FirmwarePreviews.kt:@PreviewLightDark @Composable fun DisclaimerDialogPreview</ID>
diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
index 46bacf880f..8f3907cc29 100644
--- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
+++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.firmware
import android.content.Context
+import android.provider.DocumentsContract
import android.provider.OpenableColumns
import co.touchlab.kermit.Logger
import com.eygraber.uri.toAndroidUri
@@ -29,6 +30,7 @@ import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.ioDispatcher
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileOutputStream
@@ -38,6 +40,14 @@ import java.net.URI
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
+private const val DOWNLOAD_BUFFER_SIZE = 8192
+
+/** SAF provider for physical volumes; the only one a UF2 bootloader drive can appear under. */
+private const val EXTERNAL_STORAGE_AUTHORITY = "com.android.externalstorage.documents"
+
+/** SAF volume id for internal shared storage β never a removable drive. */
+private const val PRIMARY_VOLUME_ID = "primary"
+
/**
* Helper class to handle file operations related to firmware updates, such as downloading, copying from URI, and
* extracting specific files from Zip archives.
@@ -286,6 +296,85 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean =
org.meshtastic.feature.firmware.isValidFirmwareFile(filename, target, fileExtension)
+ /**
+ * Accepts only a Storage Access Framework document on a non-primary external volume.
+ *
+ * `com.android.externalstorage.documents` document ids are `<volumeId>:<path>`, where internal shared storage is
+ * always `primary`. A mounted USB mass-storage volume β which is what a UF2 bootloader drive is β gets its own
+ * volume id. Every other provider (Downloads, Drive, MediaStore) is therefore rejected, which is the point: those
+ * are exactly where a mis-tap sends the image.
+ */
+ override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = withContext(ioDispatcher) {
+ safeCatching {
+ val androidUri = destinationUri.toAndroidUri()
+ if (androidUri.authority != EXTERNAL_STORAGE_AUTHORITY) return@safeCatching false
+ // Accepts either a tree URI (the maintenance flow picks the volume) or a single document URI.
+ val documentId =
+ runCatching { DocumentsContract.getTreeDocumentId(androidUri) }.getOrNull()
+ ?: DocumentsContract.getDocumentId(androidUri)
+ val volumeId = documentId.substringBefore(':', missingDelimiterValue = "")
+ volumeId.isNotBlank() && !volumeId.equals(PRIMARY_VOLUME_ID, ignoreCase = true)
+ }
+ .onFailure { Logger.w { "Could not classify the selected destination volume" } }
+ .getOrDefault(false)
+ }
+
+ override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = withContext(ioDispatcher) {
+ safeCatching {
+ context.contentResolver.openInputStream(destinationUri.toAndroidUri())?.use { true } == true
+ }
+ .getOrDefault(false)
+ }
+
+ override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = withContext(ioDispatcher) {
+ safeCatching {
+ val tree = treeUri.toAndroidUri()
+ val treeDocumentId = DocumentsContract.getTreeDocumentId(tree)
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree, treeDocumentId)
+ val documentId =
+ context.contentResolver
+ .query(
+ childrenUri,
+ arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ ),
+ null,
+ null,
+ null,
+ )
+ ?.use { cursor ->
+ var found: String? = null
+ while (cursor.moveToNext()) {
+ if (cursor.getString(1).equals(fileName, ignoreCase = true)) {
+ found = cursor.getString(0)
+ break
+ }
+ }
+ found
+ } ?: return@safeCatching null
+
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(tree, documentId)
+ context.contentResolver.openInputStream(documentUri)?.use { it.readBytes().decodeToString() }
+ }
+ .onFailure { Logger.w { "Could not read $fileName from the selected volume" } }
+ .getOrNull()
+ }
+
+ override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? =
+ withContext(ioDispatcher) {
+ safeCatching {
+ val tree = treeUri.toAndroidUri()
+ val parent =
+ DocumentsContract.buildDocumentUriUsingTree(tree, DocumentsContract.getTreeDocumentId(tree))
+ DocumentsContract.createDocument(context.contentResolver, parent, mimeType, fileName)?.let {
+ CommonUri.parse(it.toString())
+ }
+ }
+ .onFailure { Logger.w { "Could not create $fileName on the selected volume" } }
+ .getOrNull()
+ }
+
override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long =
withContext(ioDispatcher) {
val inputStream =
diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt
index efe43d6ad8..46270889c7 100644
--- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt
+++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareUsbManager.kt
@@ -26,11 +26,68 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
+import org.meshtastic.core.network.repository.UsbRepository
/** Manages USB-related interactions for firmware updates. */
@Single
-class AndroidFirmwareUsbManager(private val context: Context) : FirmwareUsbManager {
+class AndroidFirmwareUsbManager(private val context: Context, private val usbRepository: UsbRepository) :
+ FirmwareUsbManager {
+
+ override suspend fun serialPortKeys(): Set<String> = usbRepository.serialDevices.value.keys
+
+ @Suppress("ReturnCount") // each failed precondition must abort without poking an arbitrary port
+ override suspend fun unblockCdcPort(excluding: Set<String>, waitMillis: Long, holdMillis: Long): Boolean {
+ // The erase image enumerates a moment after the write, so poll rather than sampling once.
+ val driver =
+ withTimeoutOrNull(waitMillis) {
+ usbRepository.serialDevices
+ .map { devices -> devices.entries.firstOrNull { it.key !in excluding }?.value }
+ .filterNotNull()
+ .first()
+ }
+
+ if (driver == null) {
+ Logger.w { "No new serial port appeared within ${waitMillis}ms; cannot unblock the erase image" }
+ return false
+ }
+
+ // device_filter.xml lists no bootloader-mode ids (and none at all for Seeed's 0x2886), so no implicit grant
+ // exists here β an explicit request is the only route. Bounded by waitMillis too: requestPermission can
+ // return emptyFlow() (no UsbManager available), and first() on that throws instead of denying cleanly β
+ // firstOrNull() under a timeout treats both "empty flow" and "unanswered dialog" as a plain denial.
+ if (
+ !usbRepository.hasPermission(driver.device) &&
+ withTimeoutOrNull(waitMillis) { usbRepository.requestPermission(driver.device).firstOrNull() } != true
+ ) {
+ Logger.w { "USB permission denied; cannot unblock the erase image" }
+ return false
+ }
+
+ return usbRepository.pokeDtr(driver, holdMillis)
+ }
+
+ override suspend fun ensureSerialPermission(waitMillis: Long): Boolean = withTimeoutOrNull(waitMillis) {
+ // The updated firmware enumerates a while after the UF2 is consumed; poll rather than sampling once.
+ val driver =
+ usbRepository.serialDevices.map { devices -> devices.values.firstOrNull() }.filterNotNull().first()
+ if (usbRepository.hasPermission(driver.device)) return@withTimeoutOrNull true
+ // The permission dialog itself must share this budget β otherwise an unanswered dialog leaves the
+ // caller (the post-update verification flow) parked forever instead of falling back to auto-recovery.
+ // firstOrNull(): requestPermission can return emptyFlow() (no UsbManager available), and first() on
+ // that throws instead of denying cleanly.
+ usbRepository.requestPermission(driver.device).firstOrNull() == true
+ }
+ ?: run {
+ Logger.w { "USB permission preflight did not complete within ${waitMillis}ms" }
+ false
+ }
+
/** Observe when a USB device is detached. */
override fun deviceDetachFlow(): Flow<Unit> = callbackFlow {
val receiver =
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt
index fafa48104e..3736384286 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareFileHandler.kt
@@ -70,6 +70,45 @@ interface FirmwareFileHandler {
/** Copy [source] to the platform URI [destinationUri], returning the number of bytes written. */
suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long
+ /**
+ * Best-effort check that [destinationUri] names a location on removable storage β i.e. plausibly a mounted UF2
+ * bootloader drive rather than internal storage.
+ *
+ * Used to reject the Downloads mistake *before* writing, which matters only once a write is destructive: in a
+ * multi-pass maintenance sequence a mis-saved pass leaves the device with no application, and nothing else in the
+ * flow can tell a landed write from a lost one. The plain single-pass update path deliberately does not consult
+ * this β its worst case is "nothing happened, replug".
+ */
+ suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean
+
+ /**
+ * Reads a file sitting alongside the documents in [treeUri], or `null` when it is absent or unreadable.
+ *
+ * Used to read `INFO_UF2.TXT` off a mounted UF2 bootloader volume before writing to it. That file both identifies
+ * the board (`Board-ID:`) and reports the installed SoftDevice, and its mere presence is positive proof the picked
+ * volume really is an Adafruit-family UF2 bootloader drive rather than, say, Downloads.
+ *
+ * Requires a tree URI (`ACTION_OPEN_DOCUMENT_TREE`); a single-document URI grants no sibling access.
+ */
+ suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String?
+
+ /**
+ * Creates [fileName] inside [treeUri] and returns a URI to write to, or `null` on failure.
+ *
+ * The tree-picker counterpart to the save-file launcher: once the volume itself has been vetted, the app names the
+ * file rather than asking the user to.
+ */
+ suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri?
+
+ /**
+ * True when [destinationUri] can still be opened for reading.
+ *
+ * The success signal for a UF2 write is inverted: a UF2 bootloader consumes the image, reboots, and its volume
+ * disappears, so a destination that is *still* readable afterwards means the bytes landed somewhere persistent β
+ * the wrong place. Used as the tiebreaker when the detach broadcast is missed.
+ */
+ suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean
+
// ββ Zip / extraction βββββββββββββββββββββββββββββββββββββββββββββββββ
/**
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt
index c0c672c743..8eeb31b07a 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwarePreviews.kt
@@ -24,7 +24,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
+import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.firmware_maintenance_wrong_destination
import org.meshtastic.core.ui.theme.AppTheme
// These previews intentionally wrap-content (no fillMaxSize) so the generated reference images are tight crops of
@@ -83,3 +85,54 @@ fun SuccessStatePreview() {
fun DisclaimerDialogPreview() {
AppTheme { Surface { DisclaimerDialog(updateMethod = FirmwareUpdateMethod.Ble, onDismiss = {}, onConfirm = {}) } }
}
+
+@PreviewLightDark
+@Composable
+internal fun UsbMaintenanceCardPreview() {
+ AppTheme {
+ Surface {
+ Column(modifier = Modifier.padding(24.dp)) {
+ UsbMaintenanceCard(deviceName = "RAK4631", onBootloaderUpgrade = {})
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+internal fun UsbMaintenanceCardRefusedPreview() {
+ AppTheme {
+ Surface {
+ Column(modifier = Modifier.padding(24.dp)) {
+ WipeDeviceToggle(
+ updateMethod = FirmwareUpdateMethod.Usb,
+ refusal = UsbMaintenanceRefusal.UnknownSoftDevice,
+ wipeDevice = false,
+ onWipeDeviceChange = {},
+ )
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+internal fun AwaitingEraseFileSavePreview() {
+ AppTheme {
+ Surface {
+ Column(modifier = Modifier.padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ AwaitingFileSaveState(
+ state =
+ FirmwareUpdateState.AwaitingFileSave(
+ uf2Artifact = null,
+ fileName = null,
+ step = UsbFileSaveStep.FactoryErase,
+ retryMessage = UiText.Resource(Res.string.firmware_maintenance_wrong_destination),
+ ),
+ onSaveFile = {},
+ onPickVolume = {},
+ )
+ }
+ }
+ }
+}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
index 6a3ae77714..c850dec838 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareRetriever.kt
@@ -29,6 +29,9 @@ private val KNOWN_ARCHS = setOf("esp32-s3", "esp32-c3", "esp32-c6", "nrf52840",
private const val FIRMWARE_BASE_URL = "https://raw.githubusercontent.com/meshtastic/meshtastic.github.io/master"
+/** Radix for the hex flash addresses in maintenance-image diagnostics. */
+private const val HEX_RADIX = 16
+
/** OTA partition role in .mt.json manifests β the main application firmware. */
private const val OTA_PART_NAME = "app0"
@@ -40,14 +43,14 @@ private val manifestJson = Json {
/** Retrieves firmware files, either by direct download or by extracting from a release asset zip. */
@Single
-class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
+open class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
/**
* Download the OTA firmware zip for a Nordic (nRF52) DFU update.
*
* @return The downloaded `-ota.zip` [FirmwareArtifact], or `null` if the file could not be resolved.
*/
- suspend fun retrieveOtaFirmware(
+ open suspend fun retrieveOtaFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
@@ -64,7 +67,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
*
* @return The downloaded `.uf2` [FirmwareArtifact], or `null` if the file could not be resolved.
*/
- suspend fun retrieveUsbFirmware(
+ open suspend fun retrieveUsbFirmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
@@ -76,6 +79,61 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
internalFileExtension = ".uf2",
)
+ /**
+ * Download a pinned maintenance image (factory erase, bootloader upgrade) and verify it before returning.
+ *
+ * The module's only absolute-URL entry point. Unlike release artifacts, these images have no versioned upstream β
+ * `nrf52_factory_erase` has cut no releases β so the URL is commit/tag-pinned and the content is the real contract.
+ *
+ * A digest or address mismatch is **terminal**: the file is deleted and `null` is returned with no fallback. The
+ * release-zip fallback the other retrievers use would be actively wrong here, because the payload is destructive
+ * and "some other file with the right name" is precisely the failure being guarded against.
+ *
+ * @return The verified [FirmwareArtifact], or `null` when the download failed or verification did not pass.
+ */
+ @Suppress("ReturnCount") // guard clauses: every failure path must abort before a destructive write
+ internal open suspend fun retrieveMaintenanceUf2(
+ asset: MaintenanceUf2,
+ onProgress: (Float) -> Unit,
+ ): FirmwareArtifact? {
+ val artifact =
+ try {
+ fileHandler.downloadFile(asset.url, asset.fileName, onProgress)
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "Maintenance image download failed: ${asset.fileName}" }
+ null
+ } ?: return null
+
+ val bytes = fileHandler.readBytes(artifact)
+ val actualDigest = FirmwareHashUtil.bytesToHex(FirmwareHashUtil.calculateSha256Bytes(bytes))
+ if (!actualDigest.equals(asset.sha256, ignoreCase = true)) {
+ Logger.e {
+ "Maintenance image ${asset.fileName} digest mismatch (expected ${asset.sha256}, got $actualDigest)"
+ }
+ fileHandler.deleteFile(artifact)
+ return null
+ }
+
+ val expectedAddress = asset.expectedFirstTargetAddress
+ if (expectedAddress != null) {
+ val actualAddress = uf2FirstTargetAddress(bytes)
+ if (actualAddress != expectedAddress) {
+ // The digest proved which file arrived; this proves the pinned row itself isn't mismatched β a swapped
+ // URL/digest pair is the one authoring error a digest cannot catch, and the one that erases a
+ // SoftDevice.
+ Logger.e {
+ "Maintenance image ${asset.fileName} targets ${actualAddress?.toString(HEX_RADIX)}, " +
+ "expected ${expectedAddress.toString(HEX_RADIX)} β refusing to write"
+ }
+ fileHandler.deleteFile(artifact)
+ return null
+ }
+ }
+
+ Logger.i { "Maintenance image ${asset.fileName} verified (${bytes.size} bytes)" }
+ return artifact
+ }
+
/**
* Download the ESP32 OTA firmware binary. Tries in order:
* 1. `.mt.json` manifest resolution (2.7.17+) β the authoritative `app0` partition image.
@@ -89,7 +147,7 @@ class FirmwareRetriever(private val fileHandler: FirmwareFileHandler) {
* @return The downloaded `.bin` [FirmwareArtifact], or `null` if the file could not be resolved.
*/
@Suppress("ReturnCount")
- suspend fun retrieveEsp32Firmware(
+ open suspend fun retrieveEsp32Firmware(
release: FirmwareRelease,
hardware: DeviceHardware,
onProgress: (Float) -> Unit,
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt
index aa65f43a73..a67f85a402 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateActions.kt
@@ -20,9 +20,13 @@ import org.meshtastic.core.database.entity.FirmwareReleaseType
data class FirmwareUpdateActions(
val onReleaseTypeSelect: (FirmwareReleaseType) -> Unit,
- val onStartUpdate: () -> Unit,
+ /** Starts the update; `true` means the user opted into wiping the device as part of it (default off in the UI). */
+ val onStartUpdate: (Boolean) -> Unit,
val onPickFile: () -> Unit,
val onSaveFile: (String) -> Unit,
+ /** Pick the device's UF2 volume for a maintenance pass, which vets the drive before writing to it. */
+ val onPickVolume: () -> Unit,
+ val onBootloaderUpgrade: () -> Unit,
val onConfirmLocalFile: () -> Unit,
val onDismissLocalFile: () -> Unit,
val onRetry: () -> Unit,
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
index e2277151b6..b1cbfa9968 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt
@@ -55,6 +55,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -63,6 +64,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -94,6 +96,10 @@ import org.meshtastic.core.resources.back
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.chirpy
import org.meshtastic.core.resources.dont_show_again_for_device
+import org.meshtastic.core.resources.firmware_maintenance_select_drive
+import org.meshtastic.core.resources.firmware_maintenance_upgrade_bootloader_action
+import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_text
+import org.meshtastic.core.resources.firmware_maintenance_upgrade_confirm_title
import org.meshtastic.core.resources.firmware_recovery_button
import org.meshtastic.core.resources.firmware_recovery_explanation
import org.meshtastic.core.resources.firmware_update_almost_there
@@ -126,6 +132,7 @@ import org.meshtastic.core.resources.firmware_update_slow_bootloader_hint
import org.meshtastic.core.resources.firmware_update_source_local
import org.meshtastic.core.resources.firmware_update_stable
import org.meshtastic.core.resources.firmware_update_success
+import org.meshtastic.core.resources.firmware_update_success_wiped
import org.meshtastic.core.resources.firmware_update_taking_a_while
import org.meshtastic.core.resources.firmware_update_target
import org.meshtastic.core.resources.firmware_update_title
@@ -137,6 +144,10 @@ import org.meshtastic.core.resources.firmware_update_usb_instruction_title
import org.meshtastic.core.resources.firmware_update_verification_failed
import org.meshtastic.core.resources.firmware_update_verifying
import org.meshtastic.core.resources.firmware_update_waiting_reconnect
+import org.meshtastic.core.resources.firmware_update_wipe_ble_detail
+import org.meshtastic.core.resources.firmware_update_wipe_confirm_warning
+import org.meshtastic.core.resources.firmware_update_wipe_label
+import org.meshtastic.core.resources.firmware_update_wipe_usb_detail
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.img_chirpy
import org.meshtastic.core.resources.img_hw_unknown
@@ -156,6 +167,7 @@ import org.meshtastic.core.ui.icon.Usb
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.KeepScreenOn
+import org.meshtastic.core.ui.util.rememberOpenDocumentTreeLauncher
import org.meshtastic.core.ui.util.rememberOpenFileLauncher
import org.meshtastic.core.ui.util.rememberOpenUrl
import org.meshtastic.core.ui.util.rememberSaveFileLauncher
@@ -189,6 +201,12 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
val saveFileLauncher = rememberSaveFileLauncher { uri -> viewModel.saveDfuFile(uri) }
+ // Maintenance passes pick the whole volume, not a filename, so the app can read INFO_UF2.TXT and confirm it really
+ // is the device's update drive before writing anything to it.
+ val volumePickerLauncher = rememberOpenDocumentTreeLauncher { uri ->
+ uri?.let { viewModel.writeMaintenancePass(it) }
+ }
+
val actions =
remember(viewModel, onNavigateUp) {
FirmwareUpdateActions(
@@ -199,7 +217,9 @@ fun FirmwareUpdateScreen(onNavigateUp: () -> Unit, viewModel: FirmwareUpdateView
filePickerLauncher("*/*")
}
},
- onSaveFile = { fileName -> saveFileLauncher(fileName, "application/octet-stream") },
+ onSaveFile = { fileName -> saveFileLauncher(fileName, UF2_MIME_TYPE) },
+ onPickVolume = volumePickerLauncher,
+ onBootloaderUpgrade = viewModel::startBootloaderUpgrade,
onConfirmLocalFile = viewModel::confirmLocalFirmwareFile,
onDismissLocalFile = viewModel::dismissLocalFirmwareFile,
onRetry = viewModel::checkForUpdates,
@@ -371,6 +391,11 @@ private fun shouldKeepFirmwareScreenOn(state: FirmwareUpdateState): Boolean = wh
is FirmwareUpdateState.Verifying,
-> true
+ // A maintenance pass is mid-sequence: the device may already have no application, and the pass queue lives in
+ // the
+ // ViewModel, so letting the screen sleep (and the ViewModel clear) would strand the device.
+ is FirmwareUpdateState.AwaitingFileSave -> state.step.isDestructive || state.retryMessage != null
+
else -> false
}
@@ -404,14 +429,23 @@ private fun FirmwareUpdateContent(
is FirmwareUpdateState.Verifying -> VerifyingState()
is FirmwareUpdateState.VerificationFailed ->
- VerificationFailedState(onRetry = actions.onStartUpdate, onIgnore = actions.onDone)
+ VerificationFailedState(onRetry = { actions.onStartUpdate(false) }, onIgnore = actions.onDone)
is FirmwareUpdateState.Error -> ErrorState(error = state.error, onRetry = actions.onRetry)
is FirmwareUpdateState.Success ->
- SuccessState(onDone = actions.onDone, wasLowSpeedTransfer = state.wasLowSpeedTransfer)
+ SuccessState(
+ onDone = actions.onDone,
+ wasLowSpeedTransfer = state.wasLowSpeedTransfer,
+ deviceWasWiped = state.deviceWasWiped,
+ )
- is FirmwareUpdateState.AwaitingFileSave -> AwaitingFileSaveState(state, actions.onSaveFile)
+ is FirmwareUpdateState.AwaitingFileSave ->
+ AwaitingFileSaveState(
+ state = state,
+ onSaveFile = actions.onSaveFile,
+ onPickVolume = actions.onPickVolume,
+ )
}
}
}
@@ -453,19 +487,25 @@ private fun ReadyState(
}
var showDisclaimer by remember { mutableStateOf(false) }
+ // Deliberately not persisted anywhere: the wipe is an explicit per-update opt-in, never sticky.
+ var wipeDevice by rememberSaveable { mutableStateOf(false) }
val device = state.deviceHardware
val haptic = LocalHapticFeedback.current
+ val wipeOffer = wipeOffer(state, selectedReleaseType)
+ val wipeArmed = wipeOffer.offered && wipeDevice && wipeOffer.refusal == null
+
if (showDisclaimer) {
DisclaimerDialog(
updateMethod = state.updateMethod,
+ wipeDevice = wipeArmed,
onDismiss = { showDisclaimer = false },
onConfirm = {
showDisclaimer = false
if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
actions.onPickFile()
} else {
- actions.onStartUpdate()
+ actions.onStartUpdate(wipeArmed)
}
},
)
@@ -476,6 +516,21 @@ private fun ReadyState(
Spacer(Modifier.height(16.dp))
}
+ if (state.maintenance.showBootloaderUpgrade) {
+ UsbMaintenanceCard(deviceName = device.displayName, onBootloaderUpgrade = actions.onBootloaderUpgrade)
+ Spacer(Modifier.height(16.dp))
+ }
+
+ if (wipeOffer.offered) {
+ WipeDeviceToggle(
+ updateMethod = state.updateMethod,
+ refusal = wipeOffer.refusal,
+ wipeDevice = wipeDevice,
+ onWipeDeviceChange = { wipeDevice = it },
+ )
+ Spacer(Modifier.height(16.dp))
+ }
+
if (state.isRecovery) {
Text(
text = stringResource(Res.string.firmware_recovery_explanation),
@@ -488,61 +543,9 @@ private fun ReadyState(
Spacer(Modifier.height(16.dp))
- if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
- @OptIn(ExperimentalMaterial3ExpressiveApi::class)
- val largeHeight = ButtonDefaults.LargeContainerHeight
- @OptIn(ExperimentalMaterial3ExpressiveApi::class)
- Button(
- onClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- showDisclaimer = true
- },
- shapes = ButtonDefaults.shapesFor(largeHeight),
- modifier = Modifier.fillMaxWidth().height(largeHeight),
- ) {
- Icon(MeshtasticIcons.Folder, contentDescription = null)
- Spacer(Modifier.width(8.dp))
- Text(
- stringResource(Res.string.firmware_update_select_file),
- style = ButtonDefaults.textStyleFor(largeHeight),
- )
- }
- } else if (state.release != null) {
- @OptIn(ExperimentalMaterial3ExpressiveApi::class)
- val largeHeight = ButtonDefaults.LargeContainerHeight
- @OptIn(ExperimentalMaterial3ExpressiveApi::class)
- Button(
- onClick = {
- haptic.performHapticFeedback(HapticFeedbackType.LongPress)
- showDisclaimer = true
- },
- shapes = ButtonDefaults.shapesFor(largeHeight),
- modifier = Modifier.fillMaxWidth().height(largeHeight),
- ) {
- Icon(
- imageVector =
- when (state.updateMethod) {
- FirmwareUpdateMethod.Ble -> MeshtasticIcons.Bluetooth
- FirmwareUpdateMethod.Usb -> MeshtasticIcons.Usb
- FirmwareUpdateMethod.Wifi -> MeshtasticIcons.Wifi
- },
- contentDescription = null,
- )
- Spacer(Modifier.width(8.dp))
- Text(
- if (state.isRecovery) {
- stringResource(Res.string.firmware_recovery_button)
- } else {
- stringResource(
- resource = Res.string.firmware_update_method_detail,
- stringResource(state.updateMethod.description),
- )
- },
- style = ButtonDefaults.textStyleFor(largeHeight),
- )
- }
- Spacer(Modifier.height(24.dp))
- ReleaseNotesCard(state.release.releaseNotes)
+ ReadyActionButton(state, selectedReleaseType) {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ showDisclaimer = true
}
}
@@ -567,7 +570,12 @@ private fun UnsupportedTransportState() {
}
@Composable
-internal fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: () -> Unit, onConfirm: () -> Unit) {
+internal fun DisclaimerDialog(
+ updateMethod: FirmwareUpdateMethod,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+ wipeDevice: Boolean = false,
+) {
MeshtasticDialog(
onDismiss = onDismiss,
title = stringResource(Res.string.firmware_update_disclaimer_title),
@@ -592,6 +600,23 @@ internal fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: ()
color = MaterialTheme.colorScheme.error,
)
}
+ if (wipeDevice) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ MeshtasticIcons.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(16.dp),
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ stringResource(Res.string.firmware_update_wipe_confirm_warning),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
if (updateMethod is FirmwareUpdateMethod.Ble) {
Spacer(modifier = Modifier.height(12.dp))
ChirpyCard()
@@ -601,6 +626,134 @@ internal fun DisclaimerDialog(updateMethod: FirmwareUpdateMethod, onDismiss: ()
)
}
+/** The Ready state's primary action: pick a local file, or start the release update over the active transport. */
+@Composable
+private fun ReadyActionButton(
+ state: FirmwareUpdateState.Ready,
+ selectedReleaseType: FirmwareReleaseType,
+ onClick: () -> Unit,
+) {
+ if (state.updateMethod is FirmwareUpdateMethod.Unknown) return
+ if (selectedReleaseType != FirmwareReleaseType.LOCAL && state.release == null) return
+ val largeHeight = ButtonDefaults.LargeContainerHeight
+ Column {
+ Button(
+ onClick = onClick,
+ shapes = ButtonDefaults.shapesFor(largeHeight),
+ modifier = Modifier.fillMaxWidth().height(largeHeight),
+ ) {
+ if (selectedReleaseType == FirmwareReleaseType.LOCAL) {
+ Icon(MeshtasticIcons.Folder, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ stringResource(Res.string.firmware_update_select_file),
+ style = ButtonDefaults.textStyleFor(largeHeight),
+ )
+ } else {
+ Icon(
+ imageVector =
+ when (state.updateMethod) {
+ FirmwareUpdateMethod.Ble -> MeshtasticIcons.Bluetooth
+ FirmwareUpdateMethod.Usb -> MeshtasticIcons.Usb
+ FirmwareUpdateMethod.Wifi -> MeshtasticIcons.Wifi
+ },
+ contentDescription = null,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ if (state.isRecovery) {
+ stringResource(Res.string.firmware_recovery_button)
+ } else {
+ stringResource(
+ resource = Res.string.firmware_update_method_detail,
+ stringResource(state.updateMethod.description),
+ )
+ },
+ style = ButtonDefaults.textStyleFor(largeHeight),
+ )
+ }
+ }
+ if (selectedReleaseType != FirmwareReleaseType.LOCAL && state.release != null) {
+ Spacer(Modifier.height(24.dp))
+ ReleaseNotesCard(state.release.releaseNotes)
+ }
+ }
+}
+
+/** Whether the wipe opt-in is shown for this Ready state, and the reason it is disabled when it is. */
+internal data class WipeOffer(val offered: Boolean, val refusal: UsbMaintenanceRefusal? = null)
+
+/**
+ * A wipe can only be offered where a mechanism exists for it: the vetted UF2 erase sequence over USB, or a
+ * post-verification factory reset over BLE/WiFi. Local files and recovery flows never wipe.
+ */
+internal fun wipeOffer(state: FirmwareUpdateState.Ready, selectedReleaseType: FirmwareReleaseType): WipeOffer {
+ val offered =
+ selectedReleaseType != FirmwareReleaseType.LOCAL &&
+ state.release != null &&
+ !state.isRecovery &&
+ when (state.updateMethod) {
+ FirmwareUpdateMethod.Usb -> state.maintenance.show
+
+ FirmwareUpdateMethod.Ble,
+ FirmwareUpdateMethod.Wifi,
+ -> true
+
+ FirmwareUpdateMethod.Unknown -> false
+ }
+ val refusal = state.maintenance.eraseRefusal.takeIf { state.updateMethod is FirmwareUpdateMethod.Usb }
+ return WipeOffer(offered = offered, refusal = refusal)
+}
+
+/**
+ * Per-update opt-in for wiping the device as part of the update β always off by default.
+ *
+ * Over USB this is a true flash erase (the vetted UF2 maintenance sequence, which reinstalls the selected release
+ * afterwards); over BLE/WiFi it is a factory reset issued only after the update is verified. A USB erase the gate
+ * refuses stays **visible but disabled with its reason shown**, because the reason is the useful part β it tells the
+ * user this app can't confirm their device's SoftDevice and points them at the web flasher.
+ */
+@Composable
+internal fun WipeDeviceToggle(
+ updateMethod: FirmwareUpdateMethod,
+ refusal: UsbMaintenanceRefusal?,
+ wipeDevice: Boolean,
+ onWipeDeviceChange: (Boolean) -> Unit,
+) {
+ val enabled = refusal == null
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(Res.string.firmware_update_wipe_label),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text =
+ when (updateMethod) {
+ FirmwareUpdateMethod.Usb -> stringResource(Res.string.firmware_update_wipe_usb_detail)
+ else -> stringResource(Res.string.firmware_update_wipe_ble_detail)
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Switch(checked = wipeDevice && enabled, onCheckedChange = onWipeDeviceChange, enabled = enabled)
+ }
+ refusal?.let {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = usbMaintenanceRefusalText(it),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun ChirpyCard() {
Card(
@@ -723,6 +876,52 @@ private fun DeviceInfoCard(
}
}
+/**
+ * Offers the OTAFIX bootloader upgrade where an image exists for the device's Board-ID.
+ *
+ * Destructive, so it sits behind a confirmation and is rendered as a low-emphasis text button rather than anything that
+ * competes with the primary update action β the same treatment [BootloaderWarningCard] uses. Factory erase is not
+ * offered here: it lives on the update action itself as the [WipeDeviceToggle] opt-in, so an erase always ends with a
+ * release installed. A missing bootloader image hides the card β that is a coverage gap, not something a user can act
+ * on.
+ */
+@Composable
+internal fun UsbMaintenanceCard(deviceName: String, onBootloaderUpgrade: () -> Unit) {
+ var showUpgradeConfirmation by rememberSaveable { mutableStateOf(false) }
+
+ if (showUpgradeConfirmation) {
+ MeshtasticDialog(
+ onDismiss = { showUpgradeConfirmation = false },
+ title = stringResource(Res.string.firmware_maintenance_upgrade_confirm_title),
+ message = stringResource(Res.string.firmware_maintenance_upgrade_confirm_text, deviceName),
+ confirmText = stringResource(Res.string.firmware_maintenance_upgrade_bootloader_action),
+ onConfirm = {
+ showUpgradeConfirmation = false
+ onBootloaderUpgrade()
+ },
+ dismissText = stringResource(Res.string.cancel),
+ )
+ }
+
+ Card(modifier = Modifier.fillMaxWidth().animateContentSize()) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ TextButton(onClick = { showUpgradeConfirmation = true }) {
+ Text(stringResource(Res.string.firmware_maintenance_upgrade_bootloader_action))
+ }
+ }
+ }
+}
+
+/**
+ * Copy for a pre-flight refusal shown inline next to the disabled button.
+ *
+ * Resolves the same [usbMaintenanceRefusalMessage] mapping the ViewModel uses for a refusal raised mid-flow, so the two
+ * call sites can never drift out of sync with each other.
+ */
+@Composable
+private fun usbMaintenanceRefusalText(refusal: UsbMaintenanceRefusal): String =
+ usbMaintenanceRefusalMessage(refusal).asString()
+
@Composable
private fun BootloaderWarningCard(deviceHardware: DeviceHardware, onDismissForDevice: () -> Unit) {
val openUrl = rememberOpenUrl()
@@ -874,8 +1073,19 @@ private fun ProgressContent(
}
@Composable
-private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, onSaveFile: (String) -> Unit) {
- var showDialog by remember { mutableStateOf(true) }
+internal fun AwaitingFileSaveState(
+ state: FirmwareUpdateState.AwaitingFileSave,
+ onSaveFile: (String) -> Unit,
+ onPickVolume: () -> Unit,
+) {
+ // Keyed on the step so each leg of a multi-pass sequence re-shows its own instructions. An unkeyed remember would
+ // leave the second pass with no dialog at all, since the branch stays in composition across the transition.
+ var showDialog by rememberSaveable(state.step) { mutableStateOf(true) }
+
+ // A maintenance pass has no artifact yet: the image is chosen from what the volume reports, so the user points at
+ // the drive and the app names the file.
+ val fileName = state.fileName
+ val launchPicker = { if (fileName != null) onSaveFile(fileName) else onPickVolume() }
if (showDialog) {
MeshtasticDialog(
@@ -884,9 +1094,17 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o
confirmText = stringResource(Res.string.okay),
onConfirm = {
showDialog = false
- onSaveFile(state.fileName)
+ launchPicker()
+ },
+ text = {
+ Text(
+ if (fileName != null) {
+ stringResource(Res.string.firmware_update_usb_instruction_text)
+ } else {
+ stringResource(Res.string.firmware_maintenance_select_drive)
+ },
+ )
},
- text = { Text(stringResource(Res.string.firmware_update_usb_instruction_text)) },
dismissable = false,
)
}
@@ -899,9 +1117,19 @@ private fun AwaitingFileSaveState(state: FirmwareUpdateState.AwaitingFileSave, o
textAlign = TextAlign.Center,
)
+ state.retryMessage?.let { retry ->
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = retry.asString(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ )
+ }
+
if (!showDialog) {
Spacer(Modifier.height(16.dp))
- Button(onClick = { onSaveFile(state.fileName) }) { Text(stringResource(Res.string.save)) }
+ Button(onClick = launchPicker) { Text(stringResource(Res.string.save)) }
}
}
@@ -983,7 +1211,7 @@ internal fun ErrorState(error: UiText, onRetry: () -> Unit) {
}
@Composable
-internal fun SuccessState(wasLowSpeedTransfer: Boolean = false, onDone: () -> Unit) {
+internal fun SuccessState(wasLowSpeedTransfer: Boolean = false, deviceWasWiped: Boolean = false, onDone: () -> Unit) {
val haptic = LocalHapticFeedback.current
val openUrl = rememberOpenUrl()
LaunchedEffect(Unit) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) }
@@ -1002,6 +1230,15 @@ internal fun SuccessState(wasLowSpeedTransfer: Boolean = false, onDone: () -> Un
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
+ if (deviceWasWiped) {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(Res.string.firmware_update_success_wiped),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
// The just-finished transfer was MTU-capped (stock bootloader). Now that the device is back and it's safe to
// leave the app, offer a one-time OTAFIX upgrade tip for faster future updates.
if (wasLowSpeedTransfer) {
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt
index b765c6f00e..39a2ae47e4 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateState.kt
@@ -57,6 +57,11 @@ sealed interface FirmwareUpdateState {
* update (see [FirmwareUpdateViewModel.checkForUpdates]). Drives recovery-specific copy and routing.
*/
val isRecovery: Boolean = false,
+ /**
+ * Which USB maintenance affordances to show, precomputed by the ViewModel so the screen stays dumb and the
+ * decision stays unit-testable β the same shape as [showBootloaderWarning].
+ */
+ val maintenance: UsbMaintenanceGate = UsbMaintenanceGate(),
) : FirmwareUpdateState
/** Firmware file is being downloaded from the release server. */
@@ -83,10 +88,28 @@ sealed interface FirmwareUpdateState {
* @property wasLowSpeedTransfer True if the upload ran at the MTU-capped low speed (stock bootloader), so the
* Success screen can offer a one-time OTAFIX upgrade tip for faster future updates.
*/
- data class Success(val wasLowSpeedTransfer: Boolean = false) : FirmwareUpdateState
+ data class Success(val wasLowSpeedTransfer: Boolean = false, val deviceWasWiped: Boolean = false) :
+ FirmwareUpdateState
- /** UF2 file is ready; waiting for the user to choose a save location (USB flow). */
- data class AwaitingFileSave(val uf2Artifact: FirmwareArtifact, val fileName: String) : FirmwareUpdateState
+ /**
+ * Waiting for the user to point the app at the device's UF2 drive (USB flow).
+ *
+ * @property uf2Artifact The image to write, when it is already downloaded and verified. `null` on a maintenance
+ * pass: erase and bootloader images are chosen from what the mounted volume reports about itself, so the image
+ * cannot be known until the volume has been picked.
+ * @property fileName Display name of [uf2Artifact], or `null` for the same reason.
+ * @property step Which leg of a multi-pass sequence this is. Also the recomposition key for the instruction dialog,
+ * so each pass re-shows its own instructions.
+ * @property retryMessage Set when a previous attempt at *this* pass failed. Once a destructive image has been
+ * written the flow never abandons the user on an error screen β it re-publishes the same pass with an
+ * explanation.
+ */
+ data class AwaitingFileSave(
+ val uf2Artifact: FirmwareArtifact?,
+ val fileName: String?,
+ val step: UsbFileSaveStep = UsbFileSaveStep.Firmware,
+ val retryMessage: UiText? = null,
+ ) : FirmwareUpdateState
}
private val FORMAT_ARG_REGEX = Regex(":?\\s*%1\\\$d%?")
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 61c594b6bb..676f8eb040 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -39,6 +39,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.di.ApplicationCoroutineScope
+import org.meshtastic.core.common.state.FirmwareMaintenanceLock
import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.safeCatching
@@ -55,6 +56,7 @@ import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioPrefs
@@ -63,6 +65,9 @@ import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.firmware_maintenance_cdc_unblock_failed
+import org.meshtastic.core.resources.firmware_maintenance_copy_failed
+import org.meshtastic.core.resources.firmware_maintenance_wrong_destination
import org.meshtastic.core.resources.firmware_recovery_ble_failed
import org.meshtastic.core.resources.firmware_update_archive_missing_target
import org.meshtastic.core.resources.firmware_update_battery_low
@@ -82,12 +87,17 @@ import org.meshtastic.core.resources.firmware_update_node_info_missing
import org.meshtastic.core.resources.firmware_update_requires_bin
import org.meshtastic.core.resources.firmware_update_requires_ota_zip
import org.meshtastic.core.resources.firmware_update_requires_uf2
+import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_unknown_error
import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_unsupported_update_method
import org.meshtastic.core.resources.unknown
-private const val DEVICE_DETACH_TIMEOUT = 30_000L
+// Bench measurement (Wio Tracker L1, 2 MB UF2): consume + reboot can outlast 30 s by a hair.
+private const val DEVICE_DETACH_TIMEOUT = 60_000L
+
+/** How long the post-update permission preflight waits for the updated firmware to enumerate. */
+private const val USB_REATTACH_PERMISSION_WAIT = 30_000L
private const val VERIFY_TIMEOUT = 60_000L
private const val VERIFY_DELAY = 2000L
private const val MIN_BATTERY_LEVEL = 10
@@ -112,9 +122,12 @@ class FirmwareUpdateViewModel(
private val firmwareUpdateManager: FirmwareUpdateManager,
private val usbManager: FirmwareUsbManager,
private val fileHandler: FirmwareFileHandler,
+ private val firmwareRetriever: FirmwareRetriever,
+ private val firmwareMaintenanceLock: FirmwareMaintenanceLock,
private val applicationScope: ApplicationCoroutineScope,
private val hiddenFeaturesUnlock: HiddenFeaturesUnlock,
private val analytics: PlatformAnalytics,
+ private val nodeRestartTracker: NodeRestartTracker,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -149,6 +162,25 @@ class FirmwareUpdateViewModel(
/** Set when [checkForUpdates] enters recovery mode (disconnected + a saved record); consumed by [startUpdate]. */
private var pendingRecovery: PendingFirmwareRecovery? = null
+ /** Remaining legs of an in-flight USB maintenance sequence, head first. Empty outside a maintenance flow. */
+ private var pendingUsbPasses: List<UsbFileSavePass> = emptyList()
+
+ /** Hardware the running maintenance sequence targets; needed to choose images once a volume is read. */
+ private var maintenanceHardware: DeviceHardware? = null
+
+ /**
+ * True once an erase or bootloader image has been written, which is the point the device stops having a working
+ * application. From then on failures re-offer the pass instead of surfacing a dead end.
+ */
+ private var destructiveWriteDone = false
+
+ /**
+ * Set by [startUpdate] when the user opted into a wipe on a transport that cannot erase flash (BLE/WiFi). Consumed
+ * by [verifyUpdateResult]: the factory reset is only sent once the updated firmware is back up and verified β a
+ * device we cannot confirm updated is never wiped.
+ */
+ private var pendingFactoryResetAfterUpdate = false
+
init {
// Cleanup potential leftovers
viewModelScope.launch {
@@ -162,6 +194,11 @@ class FirmwareUpdateViewModel(
super.onCleared()
prepareJob?.cancel()
prepareJob = null
+ // A maintenance sequence's lock must not outlive this ViewModel: the user may abandon the flow between
+ // passes (e.g. navigating away after the erase leg but before picking the firmware save location), and a
+ // leaked lock would permanently suppress the radio transport's auto-reconnect for the rest of the app
+ // session β see startUsbMaintenance/advancePastPass. A no-op if no sequence was in flight.
+ firmwareMaintenanceLock.release()
// viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
// application-wide scope (SupervisorJob + ioDispatcher). ATOMIC start + NonCancellable
// context keeps cleanup running even if something tries to cancel it mid-flight.
@@ -256,6 +293,12 @@ class FirmwareUpdateViewModel(
radioPrefs.isBle(),
updateMethod = firmwareUpdateMethod,
currentFirmwareVersion = ourNode.firmwareVersion,
+ maintenance =
+ usbMaintenanceGate(
+ hardware = deviceHardware,
+ updateMethod = firmwareUpdateMethod,
+ hasRelease = release != null,
+ ),
)
}
}
@@ -324,7 +367,7 @@ class FirmwareUpdateViewModel(
* Emitted from every path that begins a flash, so the RUM action counts local-file sideloads alongside release
* updates. The method label is mapped explicitly because [FirmwareUpdateMethod] is obfuscated in release builds.
*/
- private fun trackUpdateStart(state: FirmwareUpdateState.Ready, releaseId: String) {
+ private fun trackUpdateStart(state: FirmwareUpdateState.Ready, releaseId: String, wipeDevice: Boolean = false) {
val updateMethod =
when (state.updateMethod) {
FirmwareUpdateMethod.Usb -> "usb"
@@ -334,18 +377,37 @@ class FirmwareUpdateViewModel(
}
analytics.trackAction(
"firmware_update_start",
- mapOf("update_method" to updateMethod, "is_recovery" to state.isRecovery, "release_version" to releaseId),
+ mapOf(
+ "update_method" to updateMethod,
+ "is_recovery" to state.isRecovery,
+ "release_version" to releaseId,
+ "wipe_device" to wipeDevice,
+ ),
)
}
- fun startUpdate() {
+ fun startUpdate(wipeDevice: Boolean = false) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val release = currentState.release ?: return
- trackUpdateStart(currentState, release.id)
- if (currentState.isRecovery) {
- startRecoveryUpdate(currentState, release)
- } else {
- startNormalUpdate(currentState, release)
+ // Every update starts from a clean slate: an opt-in from an earlier, failed attempt must not carry over.
+ pendingFactoryResetAfterUpdate = false
+ trackUpdateStart(currentState, release.id, wipeDevice)
+ when {
+ currentState.isRecovery -> startRecoveryUpdate(currentState, release)
+
+ // Over USB a wipe is a physical flash erase: route into the maintenance sequence, which
+ // erases via the UF2 image and then installs this release as its terminal pass.
+ wipeDevice && currentState.updateMethod is FirmwareUpdateMethod.Usb ->
+ startUsbMaintenance(UsbMaintenanceRequest.FactoryErase)
+
+ // BLE/WiFi cannot touch flash directly; the equivalent is a factory reset issued once the
+ // updated firmware is back up and verified.
+ wipeDevice -> {
+ pendingFactoryResetAfterUpdate = true
+ startNormalUpdate(currentState, release)
+ }
+
+ else -> startNormalUpdate(currentState, release)
}
}
@@ -481,9 +543,178 @@ class FirmwareUpdateViewModel(
}
}
+ // ββ USB maintenance (factory erase / bootloader upgrade) ββββββββββββββββββββββββββββββββββββββββ
+ // Factory erase has no standalone entry point: it is reached through startUpdate(wipeDevice = true)
+ // on the USB path, so a wipe always ends with this release installed.
+
+ fun startBootloaderUpgrade() = startUsbMaintenance(UsbMaintenanceRequest.BootloaderUpgrade)
+
+ @Suppress("ReturnCount") // preconditions; each missing one must abort before the device is rebooted
+ private fun startUsbMaintenance(request: UsbMaintenanceRequest) {
+ val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
+ val release = currentState.release ?: return
+
+ // Defence in depth: the screen already disables a refused erase and hides an unmapped bootloader upgrade,
+ // but never reboot a device into DFU for a request the gate would not have offered β that costs the user a
+ // pointless reboot cycle before the write-time check in chooseMaintenanceImage refuses it anyway.
+ val gate = currentState.maintenance
+ // A hidden gate carries no refusal (show=false, eraseRefusal=null), so the per-request checks below
+ // would sail past it β refuse outright before anything can reboot the device.
+ if (!gate.show) {
+ _state.value =
+ FirmwareUpdateState.Error(usbMaintenanceRefusalMessage(UsbMaintenanceRefusal.UnsupportedArchitecture))
+ return
+ }
+ when (request) {
+ UsbMaintenanceRequest.FactoryErase ->
+ gate.eraseRefusal?.let {
+ _state.value = FirmwareUpdateState.Error(usbMaintenanceRefusalMessage(it))
+ return
+ }
+
+ UsbMaintenanceRequest.BootloaderUpgrade ->
+ if (!gate.showBootloaderUpgrade) {
+ _state.value =
+ FirmwareUpdateState.Error(usbMaintenanceRefusalMessage(UsbMaintenanceRefusal.UnknownBoardId))
+ return
+ }
+ }
+
+ originalDeviceAddress = radioPrefs.devAddr.value
+ maintenanceHardware = currentState.deviceHardware
+ destructiveWriteDone = false
+
+ viewModelScope.launch {
+ // Battery check comes first: an abort here has touched nothing, and acquiring before it would leak
+ // the lock (nothing on this path releases it), suppressing transport recovery until the screen closes.
+ if (!checkBatteryLevel()) return@launch
+ // Held until the sequence finishes or fails. Without it the environmental-recovery listeners restart
+ // the radio transport mid-sequence and bind it to the erase firmware's bare CDC port.
+ firmwareMaintenanceLock.acquire()
+ updateJob?.cancel()
+ updateJob =
+ viewModelScope.launch {
+ try {
+ pendingUsbPasses =
+ performUsbMaintenance(
+ request = request,
+ release = release,
+ hardware = currentState.deviceHardware,
+ radioController = radioController,
+ nodeRepository = nodeRepository,
+ updateState = { _state.value = it },
+ retrieveUsbFirmware = firmwareRetriever::retrieveUsbFirmware,
+ )
+ // The firmware image is the last pass, so it is also what must be cleaned up if the flow dies.
+ tempFirmwareFile =
+ pendingUsbPasses.filterIsInstance<UsbFileSavePass.Prepared>().lastOrNull()?.artifact
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "USB maintenance preparation failed" }
+ _state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
+ } finally {
+ // Preparation that produced no passes never reached the device; hand the transport back.
+ if (pendingUsbPasses.isEmpty()) firmwareMaintenanceLock.release()
+ }
+ }
+ }
+ }
+
+ /**
+ * Resumes a maintenance sequence once the user has pointed at the device's UF2 volume.
+ *
+ * Distinct from [saveDfuFile] because the URI kinds differ: this takes a *tree* URI, which grants the sibling
+ * access needed to read `INFO_UF2.TXT` and vet the volume before writing. Each pass re-picks, because the device
+ * re-enumerates between passes and the previous grant no longer refers to the mounted volume.
+ */
+ @Suppress("ReturnCount") // preconditions guarding a destructive write
+ fun writeMaintenancePass(treeUri: CommonUri) {
+ val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return
+ val pass = pendingUsbPasses.firstOrNull() ?: return
+ if (pass.step != currentState.step) return
+ val hardware = maintenanceHardware ?: return
+
+ viewModelScope.launch {
+ try {
+ // Capture the ports present before the write so the erase image's port can be told from pre-existing
+ // ones.
+ val portsBefore = usbManager.serialPortKeys()
+ val result = usbPassWriter(portsBefore).write(pass, treeUri, hardware) { _state.value = it }
+ handlePassResult(pass, result)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Writing ${pass.step} failed" }
+ reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_failed))
+ }
+ }
+ }
+
+ private suspend fun handlePassResult(pass: UsbFileSavePass, result: UsbPassResult) = when (result) {
+ UsbPassResult.Written -> advancePastPass(pass)
+
+ is UsbPassResult.Refused -> reofferOrFail(pass, usbMaintenanceRefusalMessage(result.reason))
+
+ UsbPassResult.ImageDownloadFailed ->
+ reofferOrFail(pass, UiText.Resource(Res.string.firmware_update_retrieval_failed))
+
+ UsbPassResult.CopyFailed ->
+ reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_copy_failed))
+
+ UsbPassResult.WriteDidNotLand ->
+ reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_wrong_destination))
+
+ UsbPassResult.CdcUnblockFailed ->
+ reofferOrFail(pass, UiText.Resource(Res.string.firmware_maintenance_cdc_unblock_failed))
+ }
+
+ private suspend fun advancePastPass(pass: UsbFileSavePass) {
+ if (pass.step.isDestructive) destructiveWriteDone = true
+ pendingUsbPasses = pendingUsbPasses.drop(1)
+
+ val next = pendingUsbPasses.firstOrNull()
+ if (next == null) {
+ // Sequence complete: hand the device back before verifying, so the normal reconnect can run.
+ firmwareMaintenanceLock.release()
+ verifyUpdateResult(originalDeviceAddress)
+ } else {
+ _state.value = next.toAwaitingFileSave()
+ }
+ }
+
+ /**
+ * Re-offers the same pass with an explanation, or fails outright when nothing destructive has happened yet.
+ *
+ * Once an erase or bootloader image has been written the device has no application, so dropping the user on an
+ * error screen is the worst available outcome β the flow keeps offering the pass until it succeeds or they leave
+ * deliberately.
+ */
+ private fun reofferOrFail(pass: UsbFileSavePass, message: UiText) {
+ if (destructiveWriteDone) {
+ // Still mid-sequence β hold the lock, the user is being asked to retry this pass.
+ _state.value = pass.toAwaitingFileSave(retryMessage = message)
+ } else {
+ firmwareMaintenanceLock.release()
+ _state.value = FirmwareUpdateState.Error(message)
+ }
+ }
+
+ private fun usbPassWriter(portsBefore: Set<String>) = UsbPassWriter(
+ fileHandler = fileHandler,
+ retrieveMaintenanceUf2 = { asset, onProgress ->
+ firmwareRetriever.retrieveMaintenanceUf2(asset, onProgress)
+ },
+ awaitDeviceDetach = { timeout ->
+ withTimeoutOrNull(timeout) { usbManager.deviceDetachFlow().first() } != null
+ },
+ unblockCdc = { wait, hold -> usbManager.unblockCdcPort(portsBefore, wait, hold) },
+ )
+
fun saveDfuFile(uri: CommonUri) {
val currentState = _state.value as? FirmwareUpdateState.AwaitingFileSave ?: return
- val firmwareArtifact = currentState.uf2Artifact
+ // A maintenance pass carries no artifact β it goes through writeMaintenancePass with a tree URI instead.
+ val firmwareArtifact = currentState.uf2Artifact ?: return
viewModelScope.launch {
try {
@@ -496,6 +727,10 @@ class FirmwareUpdateViewModel(
withTimeoutOrNull(DEVICE_DETACH_TIMEOUT) { usbManager.deviceDetachFlow().first() }
?: Logger.w { "Timed out waiting for device to detach, assuming success" }
+ // All writes are done once the device detached. Release before verifying: for serial,
+ // verification relies on SharedRadioInterfaceService's USB auto-recovery to reconnect the
+ // radio β which is exactly what the lock suppresses. (The release in finally is a no-op then.)
+ firmwareMaintenanceLock.release()
verifyUpdateResult(originalDeviceAddress)
} catch (e: CancellationException) {
throw e
@@ -504,6 +739,13 @@ class FirmwareUpdateViewModel(
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_failed))
} finally {
cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
+ // This is also the terminal pass of a USB maintenance sequence when the FromVolume leg has already
+ // completed: the Prepared firmware artifact is saved through this pre-existing single-pass path
+ // rather than writeMaintenancePass/advancePastPass, so releasing here is the only place that
+ // sequence's lock gets freed. A no-op for a plain single-pass update, which never acquires the lock.
+ firmwareMaintenanceLock.release()
+ pendingUsbPasses = emptyList()
+ maintenanceHardware = null
}
}
}
@@ -725,6 +967,8 @@ class FirmwareUpdateViewModel(
private fun startUpdateFromFile(uri: CommonUri, pendingArtifact: FirmwareArtifact? = null) {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
+ // Local-file installs never wipe; drop any opt-in left over from an earlier, failed attempt.
+ pendingFactoryResetAfterUpdate = false
if (currentState.updateMethod is FirmwareUpdateMethod.Ble && !isValidBluetoothAddress(currentState.address)) {
_state.value = FirmwareUpdateState.Error(UiText.Resource(Res.string.firmware_update_no_device))
cleanupPendingLocalFirmwareArtifact(pendingArtifact)
@@ -828,6 +1072,10 @@ class FirmwareUpdateViewModel(
}
private suspend fun verifyUpdateResult(address: String?, wasLowSpeedTransfer: Boolean = false) {
+ // Consume the wipe opt-in up front: whatever this verification concludes, it must never leak into a
+ // later update flow that did not opt in (e.g. a retry via a local file).
+ val factoryResetAfterVerify = pendingFactoryResetAfterUpdate
+ pendingFactoryResetAfterUpdate = false
_state.value = FirmwareUpdateState.Verifying
// Trigger a fresh connection attempt by MeshService using the original prefixed address.
@@ -840,7 +1088,22 @@ class FirmwareUpdateViewModel(
// (stable-keyed) address. BLE/TCP have no such hot-plug recovery, so they still need the explicit re-address.
address?.let { fullAddr ->
if (radioPrefs.isSerial()) {
- Logger.i { "Post-update: leaving USB reconnect to USB auto-recovery for ${fullAddr.anonymize}" }
+ Logger.i { "Post-update: preflighting the USB permission grant for ${fullAddr.anonymize}" }
+ // The reboot gave the device a new USB identity, and Android scopes permission grants to the
+ // identity β without a fresh grant the auto-recovery fails with SecurityException and a healthy
+ // update lands on VerificationFailed. Ask now, while the user is still watching. On success,
+ // reconnect explicitly: auto-recovery's attach trigger already fired (and failed) while the
+ // permission dialog was still up, and it does not retry on a grant. At this point the device is
+ // enumerated and the grant is held, so the one-shot setDeviceAddress cannot land in the
+ // enumeration gap the bare-USB path avoids.
+ if (usbManager.ensureSerialPermission(USB_REATTACH_PERMISSION_WAIT)) {
+ Logger.i {
+ "Post-update: USB permission confirmed, reconnecting explicitly to ${fullAddr.anonymize}"
+ }
+ radioController.setDeviceAddress(fullAddr)
+ } else {
+ Logger.w { "Post-update USB permission preflight did not complete; relying on auto-recovery" }
+ }
} else {
Logger.i { "Post-update: Requesting MeshService to reconnect to ${fullAddr.anonymize}" }
// GATT cache invalidation is only needed for BLE reconnects β the device
@@ -867,12 +1130,41 @@ class FirmwareUpdateViewModel(
if (result == null) {
Logger.w { "Post-update verification timed out for ${address.anonymize()}" }
+ // The opted-in wipe is deliberately skipped: a device we could not verify is never wiped.
_state.value = FirmwareUpdateState.VerificationFailed
} else {
// Device is back and healthy β retire any recovery record (covers both normal and recovery updates).
pendingRecovery = null
firmwareRecoveryDataSource.clear()
- _state.value = FirmwareUpdateState.Success(wasLowSpeedTransfer)
+ val wiped = factoryResetAfterVerify && sendPostUpdateFactoryReset()
+ _state.value = FirmwareUpdateState.Success(wasLowSpeedTransfer, deviceWasWiped = wiped)
+ }
+ }
+
+ /**
+ * Sends the factory reset the user opted into alongside a BLE/WiFi update, once the updated firmware is verified.
+ *
+ * @return true when the reset was actually sent β the Success screen only claims a wipe that happened.
+ */
+ private suspend fun sendPostUpdateFactoryReset(): Boolean {
+ val nodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ if (nodeNum == null) {
+ Logger.w { "Post-update factory reset skipped: local node number unknown after reconnect" }
+ return false
+ }
+ return try {
+ // The reset reboots the device and drops the transport; mark it expected so the UI shows
+ // "restarting" instead of a surprise disconnect (same treatment as the settings reset).
+ nodeRestartTracker.expectRestart()
+ radioController.factoryReset(nodeNum, radioController.generatePacketId())
+ // The phone-side node DB describes a configuration that no longer exists.
+ nodeRepository.clearNodeDB()
+ true
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Post-update factory reset failed" }
+ false
}
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt
index d102ed4e48..d8e380d01d 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUsbManager.kt
@@ -20,4 +20,38 @@ import kotlinx.coroutines.flow.Flow
interface FirmwareUsbManager {
fun deviceDetachFlow(): Flow<Unit>
+
+ /**
+ * Records which serial ports are present, so a port that appears *after* a UF2 write can be told apart from one
+ * that was already there.
+ *
+ * Snapshot-then-diff rather than matching a VID/PID table: bootloader-mode ids are per-board and collide (four
+ * OTAFIX boards share `239A/0029`), and identifying by serial number needs a permission grant we may not hold yet.
+ */
+ suspend fun serialPortKeys(): Set<String>
+
+ /**
+ * Opens the port that appeared since [excluding] was captured and holds DTR asserted, so firmware waiting on a host
+ * will run.
+ *
+ * The nRF52 factory-erase image blocks in `while (!Serial)` before `InternalFS.format()`, and `Serial` only becomes
+ * truthy once DTR is asserted β without this the erase never starts. A failure here is therefore safe: the image is
+ * written but has destroyed nothing.
+ *
+ * @return true when a port was claimed and DTR held.
+ */
+ suspend fun unblockCdcPort(excluding: Set<String>, waitMillis: Long, holdMillis: Long): Boolean
+
+ /**
+ * Waits up to [waitMillis] for a serial device to be present and ensures the app holds USB permission for it,
+ * prompting the user when needed.
+ *
+ * Post-update verification depends on this: every USB re-enumeration is a new device identity to Android, so the
+ * grant earned before an update does not carry across the device's reboot β without a fresh one the transport's
+ * auto-recovery fails with SecurityException and verification times out on a healthy device.
+ *
+ * Advisory: reconnection still rides the transport's own recovery; a denial just means verification times out as it
+ * would have anyway.
+ */
+ suspend fun ensureSerialPermission(waitMillis: Long): Boolean
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt
new file mode 100644
index 0000000000..2e3c624f46
--- /dev/null
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/MaintenanceUf2.kt
@@ -0,0 +1,378 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
+
+/**
+ * A pinned, content-verified UF2 image used by a maintenance flow (factory erase, bootloader upgrade).
+ *
+ * Unlike release firmware β which `FirmwareRetriever` resolves against a versioned release folder β these images have
+ * no versioned upstream. `meshtastic/nrf52_factory_erase` has cut no GitHub releases, so the flasher serves them from a
+ * mutable `public/uf2/` path. Pinning the URL to a commit and verifying [sha256] before any write is what turns that
+ * mutable path back into an immutable one.
+ *
+ * @property expectedFirstTargetAddress For nRF erase images, the flash address the UF2's first block writes to. Checked
+ * against the resolved [SoftDeviceVariant] before the image is offered, because a swapped URL/digest row is the one
+ * authoring mistake a digest alone cannot catch β and the mistake that corrupts a SoftDevice. Null when the image's
+ * address carries no such invariant (RP2040, bootloader self-updates).
+ */
+internal data class MaintenanceUf2(
+ val url: String,
+ val fileName: String,
+ val sha256: String,
+ val expectedFirstTargetAddress: Long? = null,
+) {
+ init {
+ // downloadFile interpolates fileName straight into a temp path; these are compile-time constants, but assert it
+ // so a future edit can't introduce traversal.
+ require(fileName.isNotBlank() && fileName.none { it == '/' || it == '\\' } && !fileName.contains("..")) {
+ "Unsafe maintenance UF2 filename: $fileName"
+ }
+ }
+}
+
+/**
+ * Commit-pinned base for the factory-erase images checked into `meshtastic/web-flasher` at `public/uf2/`.
+ *
+ * The blobs have not changed since this commit (2024-09-03), so the pin is cheap to hold. Built from
+ * `meshtastic/nrf52_factory_erase` (GPL-3.0).
+ */
+private const val ERASE_UF2_BASE =
+ "https://raw.githubusercontent.com/meshtastic/web-flasher/0e353b5d0756c9a1b76f53be78e948fafc1ebd8a/public/uf2"
+
+/** Release-pinned base for the OTAFIX bootloader self-update images (`oltaco/β¦_OTAFIX`, MIT). */
+private const val OTAFIX_RELEASE_TAG = "0.9.2-OTAFIX2.2-BP1.3"
+
+private const val OTAFIX_BASE =
+ "https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases/download/$OTAFIX_RELEASE_TAG"
+
+/** S140 6.1.1 application start β the address `nrf_erase2.uf2` is linked for. */
+internal const val APP_START_S140_6_1_1 = 0x26000L
+
+/** S140 7.3.0 application start β the address `nrf_erase_sd7_3.uf2` is linked for. */
+internal const val APP_START_S140_7_3_0 = 0x27000L
+
+private val ERASE_S140_6_1_1 =
+ MaintenanceUf2(
+ url = "$ERASE_UF2_BASE/nrf_erase2.uf2",
+ fileName = "nrf_erase2.uf2",
+ sha256 = "4b778a3def19854415db64cb51bfd29c15b11cc46006353dd518f62d09efe3fe",
+ expectedFirstTargetAddress = APP_START_S140_6_1_1,
+ )
+
+private val ERASE_S140_7_3_0 =
+ MaintenanceUf2(
+ url = "$ERASE_UF2_BASE/nrf_erase_sd7_3.uf2",
+ fileName = "nrf_erase_sd7_3.uf2",
+ sha256 = "13941bedce009e61255c37b1524d11ca604e88c38e7588bb8b391e2998da468f",
+ expectedFirstTargetAddress = APP_START_S140_7_3_0,
+ )
+
+private val PICO_ERASE =
+ MaintenanceUf2(
+ url = "$ERASE_UF2_BASE/pico_erase.uf2",
+ fileName = "pico_erase.uf2",
+ sha256 = "08aa7d561e8b8bf2f9b061b3506fb4d8f135e832efe0f3ae978241db2da0c853",
+ )
+
+private fun otafixAsset(board: String, sha256: String): MaintenanceUf2 {
+ val name = "update-${board}_bootloader-${OTAFIX_RELEASE_TAG}_nosd.uf2"
+ return MaintenanceUf2(url = "$OTAFIX_BASE/$name", fileName = name, sha256 = sha256)
+}
+
+/**
+ * OTAFIX bootloader self-update images, keyed by the **Board-ID the device itself reports**.
+ *
+ * Every Adafruit-family bootloader writes `INFO_UF2.TXT` to its mass-storage volume containing a `Board-ID:` line
+ * (`ghostfat.c`, from each board's `UF2_BOARD_ID`). Reading that line off the mounted drive identifies the hardware
+ * authoritatively, so nothing here depends on correlating names between the two projects β which is impossible anyway:
+ * `heltec_t114` vs `heltec-mesh-node-t114`, `thinknode_m1` vs `ThinkNode-M1`, `t1000_e` vs `tracker-t1000-e`.
+ *
+ * USB VID/PID would not do: four OTAFIX boards share `239A/0029`, all three ThinkNodes share `239A/00DA`, and SenseCAP
+ * Solar P1 collides with XIAO nRF52840 BLE on `2886/0044`. Board-IDs are unique across all 14, which a test asserts.
+ *
+ * This is also the only way to resolve the XIAO nRF52840 BLE / BLE Sense split that OTAFIX's own README calls out: the
+ * two differ only by Board-ID, and installing the wrong one over UF2 is exactly what that note warns against.
+ */
+private val OTAFIX_BY_BOARD_ID: Map<String, MaintenanceUf2> =
+ mapOf(
+ "HT-n5262" to
+ otafixAsset(
+ board = "heltec_t114",
+ sha256 = "d7a51ef7e41e7ba3b5906c162e8926ca0bc88a5010436a3589991d5a99741220",
+ ),
+ "MinewSemi-MX25LE01" to
+ otafixAsset(
+ board = "minewsemi_mx25le01",
+ sha256 = "3d2039b3e22e0b350b49a8456e30709d2f8be440a239d2ed864091b1c4abc3ae",
+ ),
+ "TRACKER L1" to
+ otafixAsset(
+ board = "wio_tracker_l1",
+ sha256 = "efe1fc16f64f04deb26d171b9a3f6d81b32767ff01c40fe894924b8c682a2964",
+ ),
+ "WisBlock-RAK4631-Board" to
+ otafixAsset(
+ board = "wiscore_rak4631_board",
+ sha256 = "3509c8b01296bc6473acf5a9422f9aec857a5bbb47e80000f9b98e15b046e46a",
+ ),
+ "WisMesh-Tag" to
+ otafixAsset(
+ board = "wismesh_tag",
+ sha256 = "e1701badf22d9684d953cb5274d5d9241c3bf776c2524b935ef21a607c0bca4a",
+ ),
+ "nRF52840-SeeedSenseCAPSolarP1-v1" to
+ otafixAsset(
+ board = "sensecap_solar_p1",
+ sha256 = "efe28af706a4a3e5604390d3bf5bcbeb7470aadc957e191c7e26e8fadc2ed4e5",
+ ),
+ "nRF52840-SeeedXiao-v1" to
+ otafixAsset(
+ board = "xiao_nrf52840_ble",
+ sha256 = "49364762fb9992334fe55b3bea7f2d30e14dce4433de6c31e90036f6ec95d2a4",
+ ),
+ "nRF52840-SeeedXiaoSense-v1" to
+ otafixAsset(
+ board = "xiao_nrf52840_ble_sense",
+ sha256 = "fc492fc2e30f2f75789217c0ead68ad64917de0844e6011269755fddfe532c36",
+ ),
+ "nRF52840-T1000-E-v1" to
+ otafixAsset(board = "t1000_e", sha256 = "c1dd30cce0f250eb7ad21e8c065cef6692b53e7efcba0578371bedcfd2493cc9"),
+ "nRF52840-TEcho-v1" to
+ otafixAsset(
+ board = "lilygo_techo",
+ sha256 = "244c3ea9a783dbcfd9fabfa95d5f8bae3a52246bbe41c5414b2b6657683025c9",
+ ),
+ "nRF52840-ThinkNode-M3-v1" to
+ otafixAsset(
+ board = "thinknode_m3",
+ sha256 = "073a6b6acf1bb0ca9ea4e8f7ca3c8df1cf3992aced06650b85c00aba5fa3ff13",
+ ),
+ "nRF52840-ThinkNodeM1-v1" to
+ otafixAsset(
+ board = "thinknode_m1",
+ sha256 = "315d36a189b30bbc09c7846031483caea669b1d0c7237da9221ab2794757498f",
+ ),
+ "nRF52840-ThinkNodeM6-v1" to
+ otafixAsset(
+ board = "thinknode_m6",
+ sha256 = "a7e977a8af02946559a8c703f1d2f27e22fd1cbedf61a9bc7af3472eb86fe99f",
+ ),
+ "nRF52840-promicro" to
+ otafixAsset(
+ board = "promicro_nrf52840",
+ sha256 = "5ceb9ad4a8092f1319fc5371649eeae55b47c5ff2acd647972558017ba51dccb",
+ ),
+ )
+
+/**
+ * Meshtastic `platformioTarget`s whose products OTAFIX lists as supported.
+ *
+ * A **UX gate only** β it decides whether the upgrade action is offered, never which image is written. Being wrong here
+ * costs a user a tap and an "unsupported board" message after the drive is read; it cannot flash anything. Every
+ * correctness decision is made by [otafixUf2ForBoardId] against the Board-ID on the drive.
+ *
+ * Derived from OTAFIX's "Boards supported" list. Deliberately excludes products that merely share a build target with a
+ * supported one β WISMESH Hub/Tap, Nomadstar Meteor Pro and RAK3401 all build against `wiscore_rak4631`, and T-Echo
+ * Plus/Lite against `t-echo`, but OTAFIX ships no bootloader for them.
+ */
+private val OTAFIX_SUPPORTED_TARGETS: Set<String> =
+ setOf(
+ "rak4631",
+ "rak_wismeshtag",
+ "t-echo",
+ "heltec-mesh-node-t114",
+ "nrf52_promicro_diy_tcxo",
+ "thinknode_m1",
+ "thinknode_m3",
+ "thinknode_m6",
+ "tracker-t1000-e",
+ "seeed_wio_tracker_L1",
+ "seeed_wio_tracker_L1_eink",
+ "seeed_solar_node",
+ "seeed_xiao_nrf52840_kit",
+ )
+
+/**
+ * The factory-erase image for [hardware], or `null` when none can be resolved safely.
+ *
+ * nRF52840 requires a resolved [DeviceHardware.softDeviceVariant]: the two images are linked for different application
+ * start addresses, and the UF2 bootloader's write guard begins at `MBR_SIZE`, so the wrong one erases a SoftDevice
+ * page. There is deliberately no default branch.
+ */
+internal fun eraseUf2For(hardware: DeviceHardware): MaintenanceUf2? = when {
+ hardware.isRp2040Arc -> PICO_ERASE
+
+ hardware.isNrf52Arc ->
+ when (hardware.softDeviceVariant) {
+ SoftDeviceVariant.S140_6_1_1 -> ERASE_S140_6_1_1
+ SoftDeviceVariant.S140_7_3_0 -> ERASE_S140_7_3_0
+ null -> null
+ }
+
+ else -> null
+}
+
+/**
+ * True when OTAFIX lists a bootloader for [platformioTarget]'s product. UX gate only β see [OTAFIX_SUPPORTED_TARGETS].
+ */
+internal fun otafixSupportsTarget(platformioTarget: String): Boolean = platformioTarget in OTAFIX_SUPPORTED_TARGETS
+
+/**
+ * The OTAFIX image matching the [boardId] a device reported in its `INFO_UF2.TXT`, or `null` when unrecognized.
+ *
+ * `null` refuses the upgrade. That is the correct outcome even for a board OTAFIX supports: an unrecognized Board-ID
+ * means the installed bootloader is not one we have a verified pairing for, and writing a bootloader built for other
+ * hardware is unrecoverable without SWD.
+ */
+internal fun otafixUf2ForBoardId(boardId: String): MaintenanceUf2? = OTAFIX_BY_BOARD_ID[boardId.trim()]
+
+/** Board-IDs we ship an OTAFIX image for. Exposed for the uniqueness test. */
+internal val otafixBoardIds: Set<String>
+ get() = OTAFIX_BY_BOARD_ID.keys
+
+/**
+ * Extracts the `Board-ID:` value from the contents of a UF2 bootloader's `INFO_UF2.TXT`.
+ *
+ * Format is fixed by `ghostfat.c`: `UF2 Bootloader <ver>` / `Model: <name>` / `Board-ID: <id>` / `Date: <date>`,
+ * CRLF-separated. Returns `null` when the line is absent, which means the volume is not an Adafruit-family UF2
+ * bootloader drive β itself a reason to refuse a destructive write.
+ */
+internal fun parseUf2BoardId(infoUf2Text: String): String? = infoUf2Text
+ .lineSequence()
+ .firstOrNull { it.trimStart().startsWith(UF2_BOARD_ID_PREFIX, ignoreCase = true) }
+ ?.substringAfter(':')
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+
+/**
+ * Extracts the installed SoftDevice from the contents of a UF2 bootloader's `INFO_UF2.TXT`.
+ *
+ * `uf2_init()` appends this line at boot from `SD_ID_GET(MBR_SIZE)`/`SD_VERSION_GET(MBR_SIZE)` β i.e. read out of the
+ * MBR's registers β formatted as `SoftDevice: S<id> <major>.<minor>.<patch>`. Present in upstream Adafruit and in
+ * OTAFIX, and verified on a stock Seeed bootloader (`SoftDevice: S140 7.3.0`).
+ *
+ * This is the **authoritative** answer to the question the bundled map only estimates: not what the firmware was built
+ * against, but which SoftDevice is actually in flash. Returns `null` when the line is absent (very old bootloader),
+ * when no SoftDevice is installed, or when the id/version is not one we ship an erase image for.
+ */
+@Suppress("ReturnCount") // guard clauses; an unparseable line must yield null rather than a guess
+internal fun parseUf2SoftDevice(infoUf2Text: String): SoftDeviceVariant? {
+ val value =
+ infoUf2Text
+ .lineSequence()
+ .firstOrNull { it.trimStart().startsWith(UF2_SOFTDEVICE_PREFIX, ignoreCase = true) }
+ ?.substringAfter(':')
+ ?.trim() ?: return null
+
+ val parts = value.split(' ').filter { it.isNotBlank() }
+ if (parts.size < 2 || !parts[0].equals(SUPPORTED_SOFTDEVICE_ID, ignoreCase = true)) return null
+ return SoftDeviceVariant.fromWire(parts[1])
+}
+
+/**
+ * Which erase image a given variant needs. Total over the enum, so a new variant cannot silently reuse an old image.
+ */
+internal fun eraseUf2ForVariant(variant: SoftDeviceVariant): MaintenanceUf2 = when (variant) {
+ SoftDeviceVariant.S140_6_1_1 -> ERASE_S140_6_1_1
+ SoftDeviceVariant.S140_7_3_0 -> ERASE_S140_7_3_0
+}
+
+/** Outcome of reconciling the SoftDevice the drive reports against the bundled map. */
+internal sealed interface EraseImageResolution {
+ /** Safe to write [asset]; [variant] is the SoftDevice it is linked for. */
+ data class Resolved(val asset: MaintenanceUf2, val variant: SoftDeviceVariant) : EraseImageResolution
+
+ /**
+ * The drive and the bundled map disagree. Always a refusal: one of the two is wrong and we cannot tell which, and
+ * guessing writes an erase image into a SoftDevice. Also the signal that a map row needs correcting.
+ */
+ data class Conflict(val reported: SoftDeviceVariant, val mapped: SoftDeviceVariant) : EraseImageResolution
+
+ /** Neither source produced a variant. */
+ data object Unresolved : EraseImageResolution
+}
+
+/**
+ * Picks the nRF erase image, preferring what the device reports over what the bundled map predicted.
+ *
+ * The map ([DeviceHardware.softDeviceVariant]) is a pre-flight hint β it decides whether the action is offered before
+ * any drive is mounted. Once the drive is readable its own report wins, because it comes from the MBR rather than from
+ * a hand-authored table. A disagreement refuses rather than picking a side.
+ */
+internal fun resolveNrfEraseImage(
+ mapped: SoftDeviceVariant?,
+ reportedFromDrive: SoftDeviceVariant?,
+): EraseImageResolution = when {
+ reportedFromDrive != null && mapped != null && reportedFromDrive != mapped ->
+ EraseImageResolution.Conflict(reported = reportedFromDrive, mapped = mapped)
+
+ reportedFromDrive != null ->
+ EraseImageResolution.Resolved(eraseUf2ForVariant(reportedFromDrive), reportedFromDrive)
+
+ // No SoftDevice line: a bootloader older than the uf2_init that emits it. Fall back to the bundled hint.
+ mapped != null -> EraseImageResolution.Resolved(eraseUf2ForVariant(mapped), mapped)
+
+ else -> EraseImageResolution.Unresolved
+}
+
+/** The file every Adafruit-family UF2 bootloader exposes on its mass-storage volume. */
+internal const val INFO_UF2_FILE_NAME = "INFO_UF2.TXT"
+
+private const val UF2_BOARD_ID_PREFIX = "Board-ID:"
+
+private const val UF2_SOFTDEVICE_PREFIX = "SoftDevice:"
+
+/** All Meshtastic nRF52840 boards run the S140 SoftDevice; anything else is out of scope and refuses. */
+private const val SUPPORTED_SOFTDEVICE_ID = "S140"
+
+/** UF2 block size, per the UF2 specification. */
+internal const val UF2_BLOCK_BYTES = 512
+
+/** Byte offset of `targetAddr` within a UF2 block header. */
+internal const val UF2_TARGET_ADDR_OFFSET = 12
+
+private const val UF2_MAGIC_START0 = 0x0A324655
+
+/** Bytes in a little-endian 32-bit field, and the mask/shift used to reassemble one. */
+private const val UINT32_BYTES = 4
+
+private const val BITS_PER_BYTE = 8
+
+private const val BYTE_MASK = 0xFFL
+
+/**
+ * Reads the target flash address of the first UF2 block in [bytes], or `null` when the payload is not a UF2 image.
+ *
+ * Used to cross-check a pinned erase image against the resolved SoftDevice variant before it is written.
+ */
+@Suppress("ReturnCount") // guard clauses over a binary header
+internal fun uf2FirstTargetAddress(bytes: ByteArray): Long? {
+ if (bytes.size < UF2_BLOCK_BYTES) return null
+ if (readLittleEndianUInt32(bytes, 0) != UF2_MAGIC_START0.toLong()) return null
+ return readLittleEndianUInt32(bytes, UF2_TARGET_ADDR_OFFSET)
+}
+
+private fun readLittleEndianUInt32(bytes: ByteArray, offset: Int): Long {
+ var value = 0L
+ for (i in UINT32_BYTES - 1 downTo 0) {
+ value = (value shl BITS_PER_BYTE) or (bytes[offset + i].toLong() and BYTE_MASK)
+ }
+ return value
+}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt
new file mode 100644
index 0000000000..e326042176
--- /dev/null
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbMaintenance.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.firmware_maintenance_no_release
+import org.meshtastic.core.resources.firmware_maintenance_not_a_bootloader_volume
+import org.meshtastic.core.resources.firmware_maintenance_softdevice_conflict
+import org.meshtastic.core.resources.firmware_maintenance_unknown_board
+import org.meshtastic.core.resources.firmware_maintenance_unknown_softdevice
+import org.meshtastic.core.resources.firmware_maintenance_unsupported_device
+import org.meshtastic.core.resources.firmware_maintenance_wrong_destination
+
+/**
+ * Which leg of a multi-pass USB/UF2 sequence a file-save prompt belongs to.
+ *
+ * Also the recomposition key for the instruction dialog in `AwaitingFileSaveState`. Every sequence shipped today is
+ * (FactoryErase | BootloaderUpgrade) β Firmware, so the key always changes between passes; a future sequence with two
+ * consecutive identical steps would need a pass index added here.
+ */
+enum class UsbFileSaveStep {
+ /**
+ * The release firmware image β the only pass in a plain update, and the last pass of every maintenance sequence.
+ */
+ Firmware,
+
+ /** The factory-erase image, which wipes the internal filesystem and re-enters UF2 DFU. */
+ FactoryErase,
+
+ /** An OTAFIX bootloader self-update image. */
+ BootloaderUpgrade,
+
+ ;
+
+ /**
+ * True when writing this image destroys the device's application, making the *next* pass mandatory rather than
+ * optional. Drives back-navigation gating and the "no abort edge" retry behaviour.
+ */
+ val isDestructive: Boolean
+ get() = this != Firmware
+}
+
+/** A maintenance operation the user can start from the Ready state. */
+enum class UsbMaintenanceRequest {
+ FactoryErase,
+ BootloaderUpgrade,
+}
+
+/** Why a maintenance action cannot run. Surfaced as explanatory copy rather than silently hiding the action. */
+@Suppress("UndocumentedPublicProperty")
+enum class UsbMaintenanceRefusal {
+ /**
+ * The device's SoftDevice variant could not be resolved, so no erase image can be chosen safely. Covers an absent
+ * or malformed metadata asset, an unmapped hardware model, and a device reporting a target we have no mapping for.
+ */
+ UnknownSoftDevice,
+
+ /** The architecture has no UF2 erase path at all (ESP32, portduino). */
+ UnsupportedArchitecture,
+
+ /** No release firmware is selected, so there would be nothing to re-flash after erasing. */
+ NoFirmwareRelease,
+
+ /** The picked destination is not on removable storage, so it cannot be a mounted bootloader volume. */
+ DestinationNotRemovable,
+
+ /**
+ * The picked volume exposes no readable `INFO_UF2.TXT`, so it is not an Adafruit-family UF2 bootloader drive. Also
+ * the outcome when a device rebooted into CDC-only bootloader mode, where no mass-storage volume exists at all.
+ */
+ NotABootloaderVolume,
+
+ /**
+ * The volume's reported SoftDevice contradicts the bundled map. One of the two is wrong and we cannot tell which,
+ * so refuse β and treat the map row as suspect.
+ */
+ SoftDeviceConflict,
+
+ /** No OTAFIX image is published for the Board-ID this volume reports. */
+ UnknownBoardId,
+}
+
+/**
+ * The single mapping from [UsbMaintenanceRefusal] to user-facing copy.
+ *
+ * Shared by the ViewModel (wraps it in [org.meshtastic.feature.firmware.FirmwareUpdateState.Error] for a refusal raised
+ * mid-flow) and the pre-flight card (resolves it via [UiText.asString] next to the disabled button) so the two call
+ * sites can never drift out of sync with each other.
+ */
+internal fun usbMaintenanceRefusalMessage(reason: UsbMaintenanceRefusal): UiText = when (reason) {
+ UsbMaintenanceRefusal.UnknownSoftDevice -> UiText.Resource(Res.string.firmware_maintenance_unknown_softdevice)
+
+ UsbMaintenanceRefusal.UnsupportedArchitecture ->
+ UiText.Resource(Res.string.firmware_maintenance_unsupported_device)
+
+ UsbMaintenanceRefusal.NoFirmwareRelease -> UiText.Resource(Res.string.firmware_maintenance_no_release)
+
+ UsbMaintenanceRefusal.DestinationNotRemovable ->
+ UiText.Resource(Res.string.firmware_maintenance_wrong_destination)
+
+ UsbMaintenanceRefusal.NotABootloaderVolume ->
+ UiText.Resource(Res.string.firmware_maintenance_not_a_bootloader_volume)
+
+ UsbMaintenanceRefusal.SoftDeviceConflict -> UiText.Resource(Res.string.firmware_maintenance_softdevice_conflict)
+
+ UsbMaintenanceRefusal.UnknownBoardId -> UiText.Resource(Res.string.firmware_maintenance_unknown_board)
+}
+
+/**
+ * Which maintenance affordances the Ready state should show, precomputed by the ViewModel.
+ *
+ * Follows the `showBootloaderWarning` precedent: the screen stays dumb and the decision stays unit-testable.
+ *
+ * @property show Whether the maintenance section appears at all.
+ * @property eraseRefusal Non-null when erase is shown but cannot run; the reason is displayed and the action disabled.
+ * @property showBootloaderUpgrade Whether a bootloader-upgrade action is offered. Absent (not refused) when no image is
+ * mapped for the board β an unmapped board is a coverage gap, not a safety decision the user can act on.
+ */
+data class UsbMaintenanceGate(
+ val show: Boolean = false,
+ val eraseRefusal: UsbMaintenanceRefusal? = null,
+ val showBootloaderUpgrade: Boolean = false,
+)
+
+/**
+ * Decides which maintenance actions are available for [hardware] on [updateMethod].
+ *
+ * Pure and total: every refusal is represented, and nothing here can fall back to a default erase image.
+ *
+ * @param hasRelease Whether a firmware release is selected. Erasing without one would strand the device with no
+ * application, so the whole section is hidden.
+ */
+internal fun usbMaintenanceGate(
+ hardware: DeviceHardware,
+ updateMethod: FirmwareUpdateMethod,
+ hasRelease: Boolean,
+): UsbMaintenanceGate {
+ val uf2Architecture = hardware.isNrf52Arc || hardware.isRp2040Arc
+ if (updateMethod !is FirmwareUpdateMethod.Usb || !uf2Architecture || !hasRelease) {
+ return UsbMaintenanceGate(show = false)
+ }
+
+ val eraseRefusal =
+ when {
+ eraseUf2For(hardware) != null -> null
+ hardware.isNrf52Arc -> UsbMaintenanceRefusal.UnknownSoftDevice
+ else -> UsbMaintenanceRefusal.UnsupportedArchitecture
+ }
+
+ return UsbMaintenanceGate(
+ show = true,
+ eraseRefusal = eraseRefusal,
+ // nRF-only: RP2040 boards run no Adafruit bootloader, so OTAFIX does not apply. This is a visibility hint only
+ // β which image gets written is decided later from the Board-ID the drive reports.
+ showBootloaderUpgrade = hardware.isNrf52Arc && otafixSupportsTarget(hardware.effectiveTarget),
+ )
+}
+
+/**
+ * What a mounted UF2 bootloader volume says about itself, read from its `INFO_UF2.TXT`.
+ *
+ * @property boardId The `Board-ID:` line β unique per board, and stable across bootloader vintages.
+ * @property softDevice The installed SoftDevice, when the bootloader reports one. `null` on RP2040 (no SoftDevice
+ * exists) and on bootloaders predating the `uf2_init` that appends the line.
+ */
+internal data class MaintenanceVolume(val boardId: String, val softDevice: SoftDeviceVariant?)
+
+/** Outcome of vetting a user-picked volume before anything is written to it. */
+internal sealed interface VolumeInspection {
+ data class Accepted(val volume: MaintenanceVolume) : VolumeInspection
+
+ data class Rejected(val reason: UsbMaintenanceRefusal) : VolumeInspection
+}
+
+/**
+ * Vets [treeUri] as a UF2 bootloader volume before it is written to.
+ *
+ * Two independent checks, cheapest first: the volume must be removable, and it must expose a readable `INFO_UF2.TXT`
+ * with a `Board-ID:` line. The second is the load-bearing one β it is positive proof of an Adafruit-family bootloader
+ * drive, where a removability heuristic only makes "somewhere on internal storage" less likely. This is what stops the
+ * common mis-tap (saving into Downloads) from being indistinguishable from a successful flash.
+ */
+@Suppress("ReturnCount") // two independent vetting gates, each of which must reject immediately
+internal suspend fun inspectMaintenanceVolume(treeUri: CommonUri, fileHandler: FirmwareFileHandler): VolumeInspection {
+ if (!fileHandler.isRemovableDestination(treeUri)) {
+ return VolumeInspection.Rejected(UsbMaintenanceRefusal.DestinationNotRemovable)
+ }
+ val info =
+ fileHandler.readSiblingText(treeUri, INFO_UF2_FILE_NAME)
+ ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume)
+ val boardId = parseUf2BoardId(info) ?: return VolumeInspection.Rejected(UsbMaintenanceRefusal.NotABootloaderVolume)
+
+ return VolumeInspection.Accepted(MaintenanceVolume(boardId = boardId, softDevice = parseUf2SoftDevice(info)))
+}
+
+/** Which image to write, or why not. */
+internal sealed interface MaintenanceImageChoice {
+ data class Resolved(val asset: MaintenanceUf2) : MaintenanceImageChoice
+
+ data class Refused(val reason: UsbMaintenanceRefusal) : MaintenanceImageChoice
+}
+
+/**
+ * Chooses the image for [request], preferring what the mounted [volume] reports over what the bundled map predicted.
+ *
+ * Total over both requests and every refusal, with no default image on any path. Called only after
+ * [inspectMaintenanceVolume] accepts, and always before anything is written β so a refusal here costs the user a
+ * message, never a half-flashed device.
+ */
+internal fun chooseMaintenanceImage(
+ request: UsbMaintenanceRequest,
+ hardware: DeviceHardware,
+ volume: MaintenanceVolume,
+): MaintenanceImageChoice = when (request) {
+ UsbMaintenanceRequest.FactoryErase ->
+ if (hardware.isNrf52Arc) {
+ when (val resolution = resolveNrfEraseImage(hardware.softDeviceVariant, volume.softDevice)) {
+ is EraseImageResolution.Resolved -> MaintenanceImageChoice.Resolved(resolution.asset)
+
+ is EraseImageResolution.Conflict ->
+ MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.SoftDeviceConflict)
+
+ EraseImageResolution.Unresolved ->
+ MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnknownSoftDevice)
+ }
+ } else {
+ // RP2040: pico_erase is board-agnostic and there is no SoftDevice to reconcile.
+ eraseUf2For(hardware)?.let { MaintenanceImageChoice.Resolved(it) }
+ ?: MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnsupportedArchitecture)
+ }
+
+ UsbMaintenanceRequest.BootloaderUpgrade ->
+ otafixUf2ForBoardId(volume.boardId)?.let { MaintenanceImageChoice.Resolved(it) }
+ ?: MaintenanceImageChoice.Refused(UsbMaintenanceRefusal.UnknownBoardId)
+}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt
index d540277720..f047a37bad 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt
@@ -20,13 +20,16 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.firmware_update_copying
import org.meshtastic.core.resources.firmware_update_downloading_percent
+import org.meshtastic.core.resources.firmware_update_flashing
import org.meshtastic.core.resources.firmware_update_rebooting
import org.meshtastic.core.resources.firmware_update_retrieval_failed
import org.meshtastic.core.resources.firmware_update_usb_failed
@@ -35,6 +38,291 @@ import org.meshtastic.core.resources.getStringSuspend
private const val USB_REBOOT_DELAY = 5000L
private const val PERCENT_MAX = 100
+/**
+ * One leg of a multi-pass USB/UF2 sequence.
+ *
+ * Two shapes because the passes differ in when their image becomes knowable: the application image is downloaded and
+ * verified up front, while an erase or bootloader image is chosen from what the mounted volume reports about itself, so
+ * it cannot exist until the user has pointed at the drive.
+ */
+internal sealed interface UsbFileSavePass {
+ val step: UsbFileSaveStep
+
+ /** Image identity is decided from the mounted volume; nothing has been downloaded yet. */
+ data class FromVolume(
+ override val step: UsbFileSaveStep,
+ val request: UsbMaintenanceRequest,
+ /**
+ * True for the nRF factory-erase image, which blocks on `while (!Serial)` before formatting and needs a host to
+ * assert DTR before it will run.
+ */
+ val requiresCdcUnblock: Boolean,
+ ) : UsbFileSavePass
+
+ /** Already downloaded and verified. */
+ data class Prepared(override val step: UsbFileSaveStep, val artifact: FirmwareArtifact, val fileName: String) :
+ UsbFileSavePass
+}
+
+/**
+ * Prepares a USB maintenance sequence: fetch the application image, reboot into the bootloader, and publish the first
+ * pass.
+ *
+ * Ordering is deliberate. The **application** image is downloaded and verified *before* `rebootToDfu`, because it is
+ * what restores the device after a destructive write β we never erase without already holding the firmware to put back.
+ * The **maintenance** image cannot be fetched this early: erase and bootloader images are selected from the Board-ID
+ * and SoftDevice the mounted volume reports, and no volume exists until the device has rebooted. It is fetched later,
+ * after the volume is vetted but still before anything is written, so a failure there costs a message rather than a
+ * device.
+ *
+ * @return The ordered passes, or an empty list when preparation failed (state has already been set to an error).
+ */
+internal suspend fun performUsbMaintenance(
+ request: UsbMaintenanceRequest,
+ release: FirmwareRelease,
+ hardware: DeviceHardware,
+ radioController: RadioController,
+ nodeRepository: NodeRepository,
+ updateState: (FirmwareUpdateState) -> Unit,
+ retrieveUsbFirmware: suspend (FirmwareRelease, DeviceHardware, (Float) -> Unit) -> FirmwareArtifact?,
+): List<UsbFileSavePass> {
+ val downloadingMsg = getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs()
+ updateState(
+ FirmwareUpdateState.Downloading(ProgressState(message = UiText.DynamicString(downloadingMsg), progress = 0f)),
+ )
+
+ val firmware =
+ try {
+ retrieveUsbFirmware(release, hardware) { progress ->
+ val percent = (progress * PERCENT_MAX).toInt()
+ updateState(
+ FirmwareUpdateState.Downloading(
+ ProgressState(
+ message = UiText.DynamicString(downloadingMsg),
+ progress = progress,
+ details = "$percent%",
+ ),
+ ),
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.e(e) { "Maintenance firmware download failed" }
+ null
+ }
+
+ if (firmware == null) {
+ updateState(
+ FirmwareUpdateState.Error(
+ UiText.DynamicString(getStringSuspend(Res.string.firmware_update_retrieval_failed)),
+ ),
+ )
+ return emptyList()
+ }
+
+ val maintenanceStep =
+ when (request) {
+ UsbMaintenanceRequest.FactoryErase -> UsbFileSaveStep.FactoryErase
+ UsbMaintenanceRequest.BootloaderUpgrade -> UsbFileSaveStep.BootloaderUpgrade
+ }
+
+ updateState(FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_rebooting))))
+ radioController.rebootToDfu(nodeRepository.myNodeInfo.value?.myNodeNum ?: 0)
+ delay(USB_REBOOT_DELAY)
+
+ val passes =
+ listOf(
+ UsbFileSavePass.FromVolume(
+ step = maintenanceStep,
+ request = request,
+ // Only the nRF erase image blocks waiting for a CDC host; pico_erase runs on its own, and a bootloader
+ // self-update is consumed by the bootloader itself.
+ requiresCdcUnblock = request == UsbMaintenanceRequest.FactoryErase && hardware.isNrf52Arc,
+ ),
+ UsbFileSavePass.Prepared(
+ step = UsbFileSaveStep.Firmware,
+ artifact = firmware,
+ fileName = firmware.fileName ?: "firmware.uf2",
+ ),
+ )
+
+ updateState(passes.first().toAwaitingFileSave())
+ return passes
+}
+
+/** Outcome of attempting one pass. Every failure is typed so the caller can re-offer the same pass with copy. */
+internal sealed interface UsbPassResult {
+ data object Written : UsbPassResult
+
+ /** Volume vetting or image selection said no. Nothing was written. */
+ data class Refused(val reason: UsbMaintenanceRefusal) : UsbPassResult
+
+ data object ImageDownloadFailed : UsbPassResult
+
+ data object CopyFailed : UsbPassResult
+
+ /** The image was copied but the destination is still readable, so it did not land on a bootloader volume. */
+ data object WriteDidNotLand : UsbPassResult
+
+ /** The erase image was written but could not be unblocked, so it never ran. Nothing was destroyed. */
+ data object CdcUnblockFailed : UsbPassResult
+}
+
+private const val DETACH_TIMEOUT_MS = 60_000L
+private const val POST_WRITE_SETTLE_MS = 1_500L
+private const val CDC_UNBLOCK_WAIT_MS = 15_000L
+private const val CDC_DTR_HOLD_MS = 2_000L
+
+/**
+ * Writes one leg of a USB/UF2 sequence to a user-picked volume.
+ *
+ * Dependencies are constructor-injected rather than threaded through the call so the write itself keeps a short
+ * signature, and [unblockCdc] is a lambda so the serial plumbing can land separately from this decision logic.
+ *
+ * @property unblockCdc Opens the erase firmware's CDC port with DTR asserted so it stops waiting and formats. Returns
+ * false when no port could be claimed.
+ */
+internal class UsbPassWriter(
+ private val fileHandler: FirmwareFileHandler,
+ private val retrieveMaintenanceUf2: suspend (MaintenanceUf2, (Float) -> Unit) -> FirmwareArtifact?,
+ private val awaitDeviceDetach: suspend (Long) -> Boolean,
+ private val unblockCdc: suspend (waitMillis: Long, holdMillis: Long) -> Boolean,
+) {
+
+ /**
+ * Vets [treeUri], resolves the image if this pass needs it, writes, and confirms the write landed.
+ *
+ * Nothing is written until the volume has proved it is a UF2 bootloader drive and an image has been chosen and
+ * verified, so any refusal reaches the user with the device untouched.
+ */
+ @Suppress("ReturnCount")
+ suspend fun write(
+ pass: UsbFileSavePass,
+ treeUri: CommonUri,
+ hardware: DeviceHardware,
+ updateState: (FirmwareUpdateState) -> Unit,
+ ): UsbPassResult {
+ val inspection = inspectMaintenanceVolume(treeUri, fileHandler)
+ val volume =
+ when (inspection) {
+ is VolumeInspection.Rejected -> return UsbPassResult.Refused(inspection.reason)
+ is VolumeInspection.Accepted -> inspection.volume
+ }
+
+ val artifact =
+ when (val resolved = resolveImage(pass, hardware, volume, updateState)) {
+ is ImageResolution.Failed -> return resolved.result
+ is ImageResolution.Ready -> resolved.artifact
+ }
+
+ val fileName = artifact.fileName ?: return UsbPassResult.CopyFailed
+ updateState(FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_copying))))
+
+ val destination = fileHandler.createDocumentInTree(treeUri, fileName, UF2_MIME_TYPE)
+ if (destination == null) {
+ Logger.w { "Could not create $fileName on the picked volume" }
+ return UsbPassResult.CopyFailed
+ }
+
+ val copied =
+ safeCatching { fileHandler.copyToUri(artifact, destination) }
+ .onFailure { Logger.e(it) { "Copying $fileName to the UF2 volume failed" } }
+ .getOrNull()
+ if (copied == null) return UsbPassResult.CopyFailed
+
+ updateState(FirmwareUpdateState.Processing(ProgressState(UiText.Resource(Res.string.firmware_update_flashing))))
+
+ // A UF2 bootloader consumes the image and reboots, so the volume vanishing is the success signal. Missing the
+ // broadcast is common and harmless β the read-back below is the authority either way.
+ val detached = awaitDeviceDetach(DETACH_TIMEOUT_MS)
+ if (!detached) {
+ Logger.d { "No detach observed for $fileName; falling back to the read-back check" }
+ delay(POST_WRITE_SETTLE_MS)
+ if (fileHandler.isDestinationReadable(destination)) {
+ // Still readable means the bytes are sitting somewhere persistent, i.e. this was never a UF2 volume.
+ Logger.w { "$fileName is still readable after writing β it did not land on a bootloader volume" }
+ return UsbPassResult.WriteDidNotLand
+ }
+ }
+
+ if (pass is UsbFileSavePass.FromVolume && pass.requiresCdcUnblock) {
+ if (!unblockCdc(CDC_UNBLOCK_WAIT_MS, CDC_DTR_HOLD_MS)) {
+ // The erase image blocks before InternalFS.format(), so a failed unblock has destroyed nothing.
+ return UsbPassResult.CdcUnblockFailed
+ }
+ }
+
+ return UsbPassResult.Written
+ }
+
+ /** Either the image to write, or the result to return instead. */
+ private sealed interface ImageResolution {
+ data class Ready(val artifact: FirmwareArtifact) : ImageResolution
+
+ data class Failed(val result: UsbPassResult) : ImageResolution
+ }
+
+ /**
+ * Produces the image for [pass]: already downloaded for the application pass, or chosen from what [volume] reports
+ * and fetched now for a maintenance pass.
+ */
+ private suspend fun resolveImage(
+ pass: UsbFileSavePass,
+ hardware: DeviceHardware,
+ volume: MaintenanceVolume,
+ updateState: (FirmwareUpdateState) -> Unit,
+ ): ImageResolution = when (pass) {
+ is UsbFileSavePass.Prepared -> ImageResolution.Ready(pass.artifact)
+
+ is UsbFileSavePass.FromVolume ->
+ when (val choice = chooseMaintenanceImage(pass.request, hardware, volume)) {
+ is MaintenanceImageChoice.Refused -> ImageResolution.Failed(UsbPassResult.Refused(choice.reason))
+
+ is MaintenanceImageChoice.Resolved -> {
+ val downloadingMsg =
+ getStringSuspend(Res.string.firmware_update_downloading_percent, 0).stripFormatArgs()
+ val artifact =
+ retrieveMaintenanceUf2(choice.asset) { progress ->
+ updateState(
+ FirmwareUpdateState.Downloading(
+ ProgressState(
+ message = UiText.DynamicString(downloadingMsg),
+ progress = progress,
+ ),
+ ),
+ )
+ }
+ artifact?.let { ImageResolution.Ready(it) }
+ ?: ImageResolution.Failed(UsbPassResult.ImageDownloadFailed)
+ }
+ }
+ }
+}
+
+/** UF2 images are opaque binaries as far as the document provider is concerned. */
+internal const val UF2_MIME_TYPE = "application/octet-stream"
+
+/** The state that asks the user to point at the drive for this pass. */
+internal fun UsbFileSavePass.toAwaitingFileSave(retryMessage: UiText? = null): FirmwareUpdateState.AwaitingFileSave =
+ when (this) {
+ is UsbFileSavePass.FromVolume ->
+ FirmwareUpdateState.AwaitingFileSave(
+ uf2Artifact = null,
+ fileName = null,
+ step = step,
+ retryMessage = retryMessage,
+ )
+
+ is UsbFileSavePass.Prepared ->
+ FirmwareUpdateState.AwaitingFileSave(
+ uf2Artifact = artifact,
+ fileName = fileName,
+ step = step,
+ retryMessage = retryMessage,
+ )
+ }
+
@Suppress("LongMethod")
internal suspend fun performUsbUpdate(
release: FirmwareRelease,
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt
index 9c9fc0c8ac..506feac177 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParser.kt
@@ -48,8 +48,20 @@ internal fun parseDfuZipEntries(entries: Map<String, ByteArray>): DfuZipPackage
throw DfuException.InvalidPackage("Failed to parse manifest.json: $detail")
}
+ // Require an application image rather than accepting whatever primaryEntry promotes. A bootloader- or
+ // SoftDevice-only package has imageCount == 1, so it slipped past the warning below and was uploaded with
+ // START_DFU's image type hard-coded to APPLICATION and the sd/bl sizes zeroed β the only thing standing between a
+ // renamed OTAFIX .zip and that path is validateNrf52LocalFirmware's "-ota.zip" suffix check. #5916 left multi-image
+ // *sequencing* out of scope; this only refuses what was never flashable.
val entry =
- manifest.manifest.primaryEntry ?: throw DfuException.InvalidPackage("No firmware entry found in manifest.json")
+ manifest.manifest.application
+ ?: manifest.manifest.primaryEntry?.let { found ->
+ throw DfuException.InvalidPackage(
+ "manifest.json declares no 'application' image (found '${found.binFile}'). SoftDevice and " +
+ "bootloader packages must not be flashed as an application image.",
+ )
+ }
+ ?: throw DfuException.InvalidPackage("No firmware entry found in manifest.json")
if (manifest.manifest.imageCount > 1) {
Logger.w {
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt
index bbd4731029..d93683ecd5 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonFirmwareRetrieverTest.kt
@@ -448,6 +448,90 @@ abstract class CommonFirmwareRetrieverTest {
assertEquals("firmware-pico-2.5.0.uf2", result.fileName)
}
+ // -----------------------------------------------------------------------
+ // Pinned maintenance images (factory erase / bootloader upgrade)
+ // -----------------------------------------------------------------------
+
+ /** Builds a single valid UF2 block whose payload target address is [targetAddress]. */
+ private fun uf2Block(targetAddress: Long): ByteArray {
+ val block = ByteArray(UF2_BLOCK_BYTES)
+ fun putLe32(offset: Int, value: Long) {
+ for (i in 0 until 4) {
+ block[offset + i] = ((value shr (8 * i)) and 0xFF).toByte()
+ }
+ }
+ putLe32(0, 0x0A324655L) // magicStart0
+ putLe32(UF2_TARGET_ADDR_OFFSET, targetAddress)
+ return block
+ }
+
+ private fun maintenanceAsset(payload: ByteArray, expectedAddress: Long?) = MaintenanceUf2(
+ url = "https://example.com/uf2/nrf_erase_test.uf2",
+ fileName = "nrf_erase_test.uf2",
+ sha256 = FirmwareHashUtil.bytesToHex(FirmwareHashUtil.calculateSha256Bytes(payload)),
+ expectedFirstTargetAddress = expectedAddress,
+ )
+
+ @Test
+ fun `maintenance uf2 with matching digest and target address is returned`() = runTest {
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+ val payload = uf2Block(APP_START_S140_7_3_0)
+ val asset = maintenanceAsset(payload, APP_START_S140_7_3_0)
+ handler.existingUrls.add(asset.url)
+ handler.fileBytes[asset.fileName] = payload
+
+ val result = retriever.retrieveMaintenanceUf2(asset) {}
+
+ assertNotNull(result, "A verified maintenance image should be returned")
+ assertEquals(asset.fileName, result.fileName)
+ assertTrue(handler.deletedFiles.isEmpty(), "A verified image must not be deleted")
+ }
+
+ @Test
+ fun `maintenance uf2 digest mismatch is terminal and deletes the download`() = runTest {
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+ val payload = uf2Block(APP_START_S140_7_3_0)
+ val asset = maintenanceAsset(payload, APP_START_S140_7_3_0).copy(sha256 = "00".repeat(32))
+ handler.existingUrls.add(asset.url)
+ handler.fileBytes[asset.fileName] = payload
+
+ val result = retriever.retrieveMaintenanceUf2(asset) {}
+
+ assertNull(result, "A digest mismatch must not yield an artifact")
+ assertEquals(1, handler.deletedFiles.size, "The rejected download must be deleted")
+ }
+
+ @Test
+ fun `maintenance uf2 with wrong target address is rejected even when the digest matches`() = runTest {
+ // A swapped URL/digest row: the file is intact and matches its own digest, but it is linked for the other
+ // SoftDevice. Writing it would erase a SoftDevice page.
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+ val payload = uf2Block(APP_START_S140_6_1_1)
+ val asset = maintenanceAsset(payload, APP_START_S140_7_3_0)
+ handler.existingUrls.add(asset.url)
+ handler.fileBytes[asset.fileName] = payload
+
+ val result = retriever.retrieveMaintenanceUf2(asset) {}
+
+ assertNull(result, "A target-address mismatch must not yield an artifact")
+ assertEquals(1, handler.deletedFiles.size, "The rejected download must be deleted")
+ }
+
+ @Test
+ fun `maintenance uf2 skips the address check when the asset declares no expected address`() = runTest {
+ val handler = FakeFirmwareFileHandler()
+ val retriever = FirmwareRetriever(handler)
+ val payload = uf2Block(0x10000000L)
+ val asset = maintenanceAsset(payload, expectedAddress = null)
+ handler.existingUrls.add(asset.url)
+ handler.fileBytes[asset.fileName] = payload
+
+ assertNotNull(retriever.retrieveMaintenanceUf2(asset) {}, "No expected address means no address check")
+ }
+
// -----------------------------------------------------------------------
// Test infrastructure
// -----------------------------------------------------------------------
@@ -547,8 +631,22 @@ abstract class CommonFirmwareRetrieverTest {
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> = emptyMap()
- override suspend fun deleteFile(file: FirmwareArtifact) {}
+ /** Artifacts passed to [deleteFile], so verification-failure paths can assert cleanup happened. */
+ val deletedFiles = mutableListOf<FirmwareArtifact>()
+
+ override suspend fun deleteFile(file: FirmwareArtifact) {
+ deletedFiles.add(file)
+ }
override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long = 0L
+
+ override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = true
+
+ override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = false
+
+ override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = null
+
+ override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? =
+ null
}
}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt
new file mode 100644
index 0000000000..199ce2523b
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonMaintenanceVolumeTest.kt
@@ -0,0 +1,206 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+/**
+ * Covers volume vetting and image choice β the two decisions that gate every destructive write.
+ *
+ * Abstract because it builds [CommonUri], whose Android `actual` needs Robolectric; the concrete `jvmTest` subclass
+ * supplies the runner. Without that subclass these tests would silently not run at all.
+ */
+abstract class CommonMaintenanceVolumeTest {
+
+ private val treeUri = CommonUri.parse("content://com.android.externalstorage.documents/tree/1234-5678%3A")
+
+ private fun nrf(variant: SoftDeviceVariant?) = DeviceHardware(
+ hwModelSlug = "RAK4631",
+ platformioTarget = "rak4631",
+ architecture = "nrf52840",
+ softDeviceVariant = variant,
+ )
+
+ private fun rp2040() = DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = "pico", architecture = "rp2040")
+
+ private val rakInfo =
+ "UF2 Bootloader 0.4.3\r\nModel: WisBlock RAK4631 Board\r\n" +
+ "Board-ID: WisBlock-RAK4631-Board\r\nDate: May 20 2023\r\nSoftDevice: S140 6.1.1\r\n"
+
+ /** RP2040 BOOTSEL volumes expose an INFO_UF2.TXT too, with no SoftDevice line. */
+ private val picoInfo = "UF2 Bootloader v3.0\r\nModel: Raspberry Pi RP2\r\nBoard-ID: RPI-RP2\r\n"
+
+ private class FakeVolume(private val removable: Boolean = true, private val info: String? = null) :
+ NoopFirmwareFileHandler() {
+ var readCount = 0
+ private set
+
+ override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = removable
+
+ override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? {
+ readCount++
+ return info.takeIf { fileName.equals(INFO_UF2_FILE_NAME, ignoreCase = true) }
+ }
+ }
+
+ // ββ Volume vetting βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `a removable volume exposing INFO_UF2 is accepted with its reported identity`() = runTest {
+ val result = inspectMaintenanceVolume(treeUri, FakeVolume(info = rakInfo))
+
+ val accepted = assertIs<VolumeInspection.Accepted>(result)
+ assertEquals("WisBlock-RAK4631-Board", accepted.volume.boardId)
+ assertEquals(SoftDeviceVariant.S140_6_1_1, accepted.volume.softDevice)
+ }
+
+ @Test
+ fun `an rp2040 bootsel volume is accepted with no softdevice`() = runTest {
+ val accepted =
+ assertIs<VolumeInspection.Accepted>(inspectMaintenanceVolume(treeUri, FakeVolume(info = picoInfo)))
+
+ assertEquals("RPI-RP2", accepted.volume.boardId)
+ assertEquals(null, accepted.volume.softDevice, "RP2040 has no SoftDevice to report")
+ }
+
+ @Test
+ fun `internal storage is rejected before INFO_UF2 is even read`() = runTest {
+ // This is the Downloads mis-tap. Rejecting on the cheap check first means no I/O against a wrong volume.
+ val fake = FakeVolume(removable = false, info = rakInfo)
+
+ val rejected = assertIs<VolumeInspection.Rejected>(inspectMaintenanceVolume(treeUri, fake))
+
+ assertEquals(UsbMaintenanceRefusal.DestinationNotRemovable, rejected.reason)
+ assertEquals(0, fake.readCount, "Must not read from a volume already known to be wrong")
+ }
+
+ @Test
+ fun `a removable volume without INFO_UF2 is not a bootloader drive`() = runTest {
+ // Also the CDC-only bootloader case, where no mass-storage volume exists at all.
+ val rejected = assertIs<VolumeInspection.Rejected>(inspectMaintenanceVolume(treeUri, FakeVolume(info = null)))
+
+ assertEquals(UsbMaintenanceRefusal.NotABootloaderVolume, rejected.reason)
+ }
+
+ @Test
+ fun `an INFO_UF2 without a board id is not a bootloader drive`() = runTest {
+ val fake = FakeVolume(info = "UF2 Bootloader 0.4.3\r\nModel: Something\r\n")
+
+ val rejected = assertIs<VolumeInspection.Rejected>(inspectMaintenanceVolume(treeUri, fake))
+
+ assertEquals(UsbMaintenanceRefusal.NotABootloaderVolume, rejected.reason)
+ }
+
+ // ββ Image choice βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `erase uses the volume's softdevice when it agrees with the map`() {
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.FactoryErase,
+ nrf(SoftDeviceVariant.S140_6_1_1),
+ MaintenanceVolume("WisBlock-RAK4631-Board", SoftDeviceVariant.S140_6_1_1),
+ )
+
+ assertEquals("nrf_erase2.uf2", assertIs<MaintenanceImageChoice.Resolved>(choice).asset.fileName)
+ }
+
+ @Test
+ fun `erase refuses when the volume and the map disagree`() {
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.FactoryErase,
+ nrf(SoftDeviceVariant.S140_6_1_1),
+ MaintenanceVolume("WisBlock-RAK4631-Board", SoftDeviceVariant.S140_7_3_0),
+ )
+
+ assertEquals(
+ UsbMaintenanceRefusal.SoftDeviceConflict,
+ assertIs<MaintenanceImageChoice.Refused>(choice).reason,
+ "A disagreement must refuse β writing either image risks the SoftDevice",
+ )
+ }
+
+ @Test
+ fun `erase falls back to the map when the volume reports no softdevice`() {
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.FactoryErase,
+ nrf(SoftDeviceVariant.S140_7_3_0),
+ MaintenanceVolume("SomeBoard", softDevice = null),
+ )
+
+ assertEquals("nrf_erase_sd7_3.uf2", assertIs<MaintenanceImageChoice.Resolved>(choice).asset.fileName)
+ }
+
+ @Test
+ fun `erase refuses when neither the volume nor the map knows the softdevice`() {
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.FactoryErase,
+ nrf(variant = null),
+ MaintenanceVolume("SomeBoard", softDevice = null),
+ )
+
+ assertEquals(UsbMaintenanceRefusal.UnknownSoftDevice, assertIs<MaintenanceImageChoice.Refused>(choice).reason)
+ }
+
+ @Test
+ fun `erase on rp2040 resolves without consulting a softdevice`() {
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.FactoryErase,
+ rp2040(),
+ MaintenanceVolume("RPI-RP2", softDevice = null),
+ )
+
+ assertEquals("pico_erase.uf2", assertIs<MaintenanceImageChoice.Resolved>(choice).asset.fileName)
+ }
+
+ @Test
+ fun `bootloader upgrade resolves from the volume board id rather than the build target`() {
+ // Hardware reports itself as a T-Echo while the catalog target says rak4631: the volume wins, because it is the
+ // thing physically in front of us.
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.BootloaderUpgrade,
+ nrf(SoftDeviceVariant.S140_6_1_1),
+ MaintenanceVolume("nRF52840-TEcho-v1", SoftDeviceVariant.S140_6_1_1),
+ )
+
+ assertTrue(assertIs<MaintenanceImageChoice.Resolved>(choice).asset.fileName.contains("lilygo_techo"))
+ }
+
+ @Test
+ fun `bootloader upgrade refuses an unrecognized board id`() {
+ val choice =
+ chooseMaintenanceImage(
+ UsbMaintenanceRequest.BootloaderUpgrade,
+ nrf(SoftDeviceVariant.S140_6_1_1),
+ MaintenanceVolume("RPI-RP2", softDevice = null),
+ )
+
+ assertEquals(UsbMaintenanceRefusal.UnknownBoardId, assertIs<MaintenanceImageChoice.Refused>(choice).reason)
+ }
+}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt
index ad64387814..cf4e3e155c 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/CommonPerformUsbUpdateTest.kt
@@ -112,8 +112,9 @@ abstract class CommonPerformUsbUpdateTest {
)
val awaitingState = states.filterIsInstance<FirmwareUpdateState.AwaitingFileSave>().first()
+ // A plain update always carries its artifact; only maintenance passes defer the filename to the volume read.
assertTrue(
- awaitingState.fileName.endsWith(".uf2"),
+ awaitingState.fileName?.endsWith(".uf2") == true,
"Expected filename to end with .uf2, got: ${awaitingState.fileName}",
)
}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index 6e836147af..1ffda624af 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.common.state.FirmwareMaintenanceLock
import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.datastore.BootloaderWarningDataSource
@@ -39,6 +40,7 @@ import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.testing.FakeNodeRepository
@@ -69,6 +71,7 @@ class FirmwareUpdateIntegrationTest {
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
+ private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val stableRelease = FirmwareRelease(id = "1", title = "2.5.0", zipUrl = "url", releaseNotes = "")
@@ -115,9 +118,12 @@ class FirmwareUpdateIntegrationTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ firmwareRetriever,
+ FirmwareMaintenanceLock(),
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
analytics,
+ NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
)
@Test
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index b5e6930983..6f3a666265 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -24,6 +24,8 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
+import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,17 +34,21 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.common.state.FirmwareMaintenanceLock
import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.datastore.model.PendingFirmwareRecovery
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.resources.Res
@@ -78,6 +84,7 @@ class FirmwareUpdateViewModelTest {
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
+ private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private lateinit var viewModel: FirmwareUpdateViewModel
@@ -139,9 +146,12 @@ class FirmwareUpdateViewModelTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ firmwareRetriever,
+ FirmwareMaintenanceLock(),
TestApplicationCoroutineScope(testDispatcher),
hiddenFeaturesUnlock,
analytics,
+ NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
)
@Test
@@ -212,7 +222,12 @@ class FirmwareUpdateViewModelTest {
verify {
analytics.trackAction(
"firmware_update_start",
- mapOf("update_method" to "ble", "is_recovery" to false, "release_version" to "1"),
+ mapOf(
+ "update_method" to "ble",
+ "is_recovery" to false,
+ "release_version" to "1",
+ "wipe_device" to false,
+ ),
)
}
}
@@ -243,6 +258,77 @@ class FirmwareUpdateViewModelTest {
)
}
+ @Test
+ fun `startUpdate with wipe factory-resets only after verification succeeds`() = runTest {
+ advanceUntilIdle()
+
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
+ .calls {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.Success())
+ null
+ }
+ // Start while disconnected: verification blocks on reconnect, and the reset must not have fired yet.
+ viewModel.startUpdate(wipeDevice = true)
+ runCurrent()
+
+ assertIs<FirmwareUpdateState.Verifying>(viewModel.state.value)
+ assertTrue(radioController.factoryResetCalls.isEmpty(), "no reset before the device is back and verified")
+
+ radioController.setConnectionState(ConnectionState.Connected)
+ advanceUntilIdle()
+
+ val state = viewModel.state.value
+ assertIs<FirmwareUpdateState.Success>(state)
+ assertTrue(state.deviceWasWiped, "Success must report the wipe it performed")
+ assertEquals(listOf(123), radioController.factoryResetCalls)
+ }
+
+ @Test
+ fun `startUpdate with wipe never factory-resets when verification fails`() = runTest {
+ advanceUntilIdle()
+
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
+ .calls {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.Success())
+ null
+ }
+ // Never reconnects β verification times out. A device we cannot confirm updated is never wiped.
+
+ viewModel.startUpdate(wipeDevice = true)
+ advanceUntilIdle()
+
+ assertIs<FirmwareUpdateState.VerificationFailed>(viewModel.state.value)
+ assertTrue(radioController.factoryResetCalls.isEmpty(), "no factory reset without a verified update")
+ }
+
+ @Test
+ fun `startUpdate without wipe never factory-resets`() = runTest {
+ advanceUntilIdle()
+
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) }
+ .calls {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.Success())
+ null
+ }
+ radioController.setConnectionState(ConnectionState.Connected)
+
+ viewModel.startUpdate()
+ advanceUntilIdle()
+
+ val state = viewModel.state.value
+ assertIs<FirmwareUpdateState.Success>(state)
+ assertFalse(state.deviceWasWiped)
+ assertTrue(radioController.factoryResetCalls.isEmpty(), "default update path must not wipe")
+ // The USB permission preflight is serial-only; a BLE verify must not touch the USB stack.
+ verifySuspend(VerifyMode.not) { usbManager.ensureSerialPermission(any()) }
+ }
+
@Test
fun `cancelUpdate goes back to Ready`() = runTest {
advanceUntilIdle()
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt
new file mode 100644
index 0000000000..068f625be7
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/NoopFirmwareFileHandler.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.model.DeviceHardware
+
+/**
+ * A do-nothing [FirmwareFileHandler] for tests to subclass, overriding only what they exercise.
+ *
+ * Hand-rolled rather than a mocking framework, matching the convention in `CommonPerformUsbUpdateTest` β this module's
+ * shared tests run on KMP targets where mokkery is not available everywhere.
+ *
+ * Defaults are chosen to be inert rather than plausible: nothing exists, nothing is removable, nothing is readable. A
+ * test that forgets to override the method it depends on fails rather than accidentally passing against a permissive
+ * default β which matters here, where a permissive default would mean "yes, that's a bootloader volume".
+ */
+abstract class NoopFirmwareFileHandler : FirmwareFileHandler {
+ override fun cleanupAllTemporaryFiles() = Unit
+
+ override suspend fun deleteFile(file: FirmwareArtifact) = Unit
+
+ override suspend fun checkUrlExists(url: String): Boolean = false
+
+ override suspend fun fetchText(url: String): String? = null
+
+ override suspend fun downloadFile(url: String, fileName: String, onProgress: (Float) -> Unit): FirmwareArtifact? =
+ null
+
+ override suspend fun getFileSize(file: FirmwareArtifact): Long = 0L
+
+ override suspend fun readBytes(artifact: FirmwareArtifact): ByteArray = ByteArray(0)
+
+ override suspend fun importFromUri(uri: CommonUri): FirmwareArtifact? = null
+
+ override suspend fun getDisplayName(uri: CommonUri): String? = null
+
+ // Fails rather than returning a size: UsbPassWriter treats any Long as a successful copy, so a test that
+ // reaches a copy without overriding this would silently pass the destructive path.
+ override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long =
+ error("test must override copyToUri to reach a copy")
+
+ override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = false
+
+ override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean = false
+
+ override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = null
+
+ override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? = null
+
+ override suspend fun extractFirmware(
+ uri: CommonUri,
+ hardware: DeviceHardware,
+ fileExtension: String,
+ preferredFilename: String?,
+ ): FirmwareArtifact? = null
+
+ override suspend fun extractFirmwareFromZip(
+ zipFile: FirmwareArtifact,
+ hardware: DeviceHardware,
+ fileExtension: String,
+ preferredFilename: String?,
+ ): FirmwareArtifact? = null
+
+ override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> = emptyMap()
+}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt
new file mode 100644
index 0000000000..80da832e14
--- /dev/null
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/UsbMaintenanceGateTest.kt
@@ -0,0 +1,398 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Tests the pure availability gate and pinned-asset resolvers.
+ *
+ * These are the safety-critical decisions for factory erase, so they live in a pure function with no fakes: the
+ * dangerous direction (writing a wrong-SoftDevice erase image) cannot be tested on hardware without destroying a
+ * device, which makes table-driven unit coverage of the refusal logic the only real verification available.
+ *
+ * Concrete (not a `Common*Test` base) because nothing here touches `CommonUri`, so it needs no platform subclass and
+ * cannot silently contribute zero tests.
+ */
+class UsbMaintenanceGateTest {
+
+ private fun nrf(
+ variant: SoftDeviceVariant? = SoftDeviceVariant.S140_6_1_1,
+ target: String = "rak4631",
+ slug: String = "RAK4631",
+ ) = DeviceHardware(
+ hwModelSlug = slug,
+ platformioTarget = target,
+ architecture = "nrf52840",
+ softDeviceVariant = variant,
+ )
+
+ private fun rp2040(target: String = "pico") =
+ DeviceHardware(hwModelSlug = "RPI_PICO", platformioTarget = target, architecture = "rp2040")
+
+ private fun esp32() =
+ DeviceHardware(hwModelSlug = "HELTEC_V3", platformioTarget = "heltec-v3", architecture = "esp32-s3")
+
+ // ββ Architecture and transport gating (R11) ββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `gate is shown for nrf52840 over usb with a release`() {
+ val gate = usbMaintenanceGate(nrf(), FirmwareUpdateMethod.Usb, hasRelease = true)
+
+ assertTrue(gate.show, "nRF52840 over USB should offer maintenance")
+ assertNull(gate.eraseRefusal, "A resolved SoftDevice must not refuse")
+ }
+
+ @Test
+ fun `gate is shown for rp2040 over usb and never refuses on softdevice`() {
+ val gate = usbMaintenanceGate(rp2040(), FirmwareUpdateMethod.Usb, hasRelease = true)
+
+ assertTrue(gate.show, "RP2040 over USB should offer maintenance")
+ assertNull(gate.eraseRefusal, "RP2040 has no SoftDevice to resolve")
+ assertFalse(gate.showBootloaderUpgrade, "OTAFIX is nRF-only")
+ }
+
+ @Test
+ fun `gate is hidden for esp32 even over usb`() {
+ assertFalse(usbMaintenanceGate(esp32(), FirmwareUpdateMethod.Usb, hasRelease = true).show)
+ }
+
+ @Test
+ fun `gate is hidden for every non-usb transport`() {
+ listOf(FirmwareUpdateMethod.Ble, FirmwareUpdateMethod.Wifi, FirmwareUpdateMethod.Unknown).forEach { method ->
+ assertFalse(
+ usbMaintenanceGate(nrf(), method, hasRelease = true).show,
+ "Maintenance must not be offered over $method β the flow needs the UF2 mass-storage drive",
+ )
+ }
+ }
+
+ @Test
+ fun `gate is hidden without a release because there would be nothing to reflash`() {
+ assertFalse(usbMaintenanceGate(nrf(), FirmwareUpdateMethod.Usb, hasRelease = false).show)
+ }
+
+ // ββ Fail-closed SoftDevice refusal (R4) ββββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `unresolved softdevice shows the action but refuses it`() {
+ val gate = usbMaintenanceGate(nrf(variant = null), FirmwareUpdateMethod.Usb, hasRelease = true)
+
+ assertTrue(gate.show, "The action stays visible so the refusal can be explained")
+ assertEquals(UsbMaintenanceRefusal.UnknownSoftDevice, gate.eraseRefusal)
+ }
+
+ @Test
+ fun `no erase image is resolved for an unresolved softdevice`() {
+ assertNull(eraseUf2For(nrf(variant = null)), "An unknown variant must never fall back to a default image")
+ }
+
+ @Test
+ fun `each softdevice variant resolves to its own image and target address`() {
+ val six = eraseUf2For(nrf(variant = SoftDeviceVariant.S140_6_1_1))
+ val seven = eraseUf2For(nrf(variant = SoftDeviceVariant.S140_7_3_0))
+
+ assertNotNull(six)
+ assertNotNull(seven)
+ assertEquals("nrf_erase2.uf2", six.fileName)
+ assertEquals("nrf_erase_sd7_3.uf2", seven.fileName)
+ assertEquals(APP_START_S140_6_1_1, six.expectedFirstTargetAddress)
+ assertEquals(APP_START_S140_7_3_0, seven.expectedFirstTargetAddress)
+ assertTrue(six.sha256 != seven.sha256, "The two variants must not share a digest")
+ }
+
+ @Test
+ fun `esp32 resolves no erase image`() {
+ assertNull(eraseUf2For(esp32()))
+ }
+
+ @Test
+ fun `rp2040 resolves the pico erase image with no address invariant`() {
+ val asset = eraseUf2For(rp2040())
+
+ assertNotNull(asset)
+ assertEquals("pico_erase.uf2", asset.fileName)
+ assertNull(asset.expectedFirstTargetAddress, "RP2040 carries no variant-to-address invariant")
+ }
+
+ // ββ OTAFIX bootloader image resolution (R5) ββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `otafix is offered only for supported targets`() {
+ assertTrue(
+ usbMaintenanceGate(nrf(target = "rak4631"), FirmwareUpdateMethod.Usb, hasRelease = true)
+ .showBootloaderUpgrade,
+ )
+ assertTrue(
+ usbMaintenanceGate(nrf(target = "heltec-mesh-node-t114"), FirmwareUpdateMethod.Usb, hasRelease = true)
+ .showBootloaderUpgrade,
+ "T114 is on OTAFIX's supported list even though the project names differ",
+ )
+ }
+
+ @Test
+ fun `otafix is not offered for products that merely share a supported build target`() {
+ // WISMESH Hub/Tap, Nomadstar Meteor Pro and RAK3401 all build against wiscore_rak4631, and T-Echo Plus/Lite
+ // against t-echo, but OTAFIX ships no bootloader for those products.
+ listOf(
+ "rak2560",
+ "rak_wismeshtap",
+ "rak4631_nomadstar_meteor_pro",
+ "rak3401-1watt",
+ "t-echo-plus",
+ "t-echo-lite",
+ )
+ .forEach { target ->
+ assertFalse(
+ usbMaintenanceGate(nrf(target = target), FirmwareUpdateMethod.Usb, hasRelease = true)
+ .showBootloaderUpgrade,
+ "$target is not an OTAFIX-supported product",
+ )
+ }
+ }
+
+ // ββ Board-ID resolution: the actual safety gate (R5) βββββββββββββββββββββ
+
+ @Test
+ fun `every shipped otafix image resolves and no two boards share a digest or filename`() {
+ assertEquals(14, otafixBoardIds.size, "OTAFIX 2.2-BP1.3 ships 14 update images")
+ val images = otafixBoardIds.map { assertNotNull(otafixUf2ForBoardId(it), "no image for $it") }
+ assertEquals(images.size, images.map { it.sha256 }.toSet().size, "No two boards may share a digest")
+ assertEquals(images.size, images.map { it.fileName }.toSet().size, "No two boards may share a filename")
+ }
+
+ @Test
+ fun `board id selects the image rather than the build target`() {
+ val rak = otafixUf2ForBoardId("WisBlock-RAK4631-Board")
+ val techo = otafixUf2ForBoardId("nRF52840-TEcho-v1")
+
+ assertNotNull(rak)
+ assertNotNull(techo)
+ assertTrue(rak.fileName.contains("wiscore_rak4631_board"))
+ assertTrue(techo.fileName.contains("lilygo_techo"))
+ // Both boards report USB 239A/0029 in bootloader mode, so USB identity could not have told them apart.
+ assertTrue(rak.sha256 != techo.sha256)
+ }
+
+ @Test
+ fun `xiao sense is distinguished from plain xiao only by board id`() {
+ val plain = otafixUf2ForBoardId("nRF52840-SeeedXiao-v1")
+ val sense = otafixUf2ForBoardId("nRF52840-SeeedXiaoSense-v1")
+
+ assertNotNull(plain)
+ assertNotNull(sense)
+ assertTrue(plain.fileName.contains("xiao_nrf52840_ble_bootloader"))
+ assertTrue(sense.fileName.contains("xiao_nrf52840_ble_sense"))
+ assertTrue(plain.sha256 != sense.sha256, "OTAFIX's README warns these must not be interchanged")
+ }
+
+ @Test
+ fun `an unrecognized board id refuses rather than falling back`() {
+ assertNull(otafixUf2ForBoardId("SomeOtherBoard-v9"))
+ assertNull(otafixUf2ForBoardId(""))
+ }
+
+ @Test
+ fun `board id is parsed from an INFO_UF2 payload and tolerates surrounding lines`() {
+ val info =
+ "UF2 Bootloader 0.9.2-OTAFIX2.2-BP1.3\r\n" +
+ "Model: WisBlock RAK4631 Board\r\n" +
+ "Board-ID: WisBlock-RAK4631-Board\r\n" +
+ "Date: Apr 13 2026\r\n"
+
+ assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(info))
+ assertNotNull(parseUf2BoardId(info)?.let { otafixUf2ForBoardId(it) })
+ }
+
+ @Test
+ fun `a payload without a board id line yields null`() {
+ assertNull(parseUf2BoardId("UF2 Bootloader 0.2.6\r\nModel: Something\r\n"))
+ assertNull(parseUf2BoardId(""), "A volume with no INFO_UF2.TXT is not a UF2 bootloader drive")
+ }
+
+ @Test
+ fun `board id tolerates leading whitespace on its line just like the softdevice line`() {
+ assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(" Board-ID: WisBlock-RAK4631-Board\r\n"))
+ }
+
+ // ββ SoftDevice read from the drive: the authoritative gate (R4) βββββββββββ
+
+ /** Verbatim `INFO_UF2.TXT` from a stock Seeed Wio Tracker L1 (hwModel 99), captured 2026-07-30. */
+ private val seeedL1Info =
+ "UF2 Bootloader 0.9.2-dirty lib/nrfx (v2.0.0) lib/tinyusb (0.12.0-145-g9775e7691) " +
+ "lib/uf2 (remotes/origin/configupdate-9-gadbb8c7)\r\n" +
+ "Model: Seeed TRACKER L1\r\n" +
+ "Board-ID: TRACKER L1\r\n" +
+ "Date: May 15 2025\r\n" +
+ "SoftDevice: S140 7.3.0\r\n"
+
+ @Test
+ fun `softdevice and board id are both read from a real stock bootloader payload`() {
+ assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice(seeedL1Info))
+ assertEquals("TRACKER L1", parseUf2BoardId(seeedL1Info))
+ // The stock Seeed bootloader reports the same Board-ID as OTAFIX's build for this board.
+ assertNotNull(otafixUf2ForBoardId(parseUf2BoardId(seeedL1Info)!!))
+ }
+
+ /** Verbatim `INFO_UF2.TXT` from a RAK4631 running OTAFIX 2.2-BP1.3 (hwModel 9), captured 2026-07-30. */
+ private val rak4631OtafixInfo =
+ "UF2 Bootloader 0.9.2-OTAFIX2.2-BP1.3 lib/nrfx (v2.0.0) lib/tinyusb (0.12.0-145-g9775e7691) " +
+ "lib/uf2 (remotes/origin/configupdate-9-gadbb8c7)\r\n" +
+ "Model: WisBlock RAK4631 Board\r\n" +
+ "Board-ID: WisBlock-RAK4631-Board\r\n" +
+ "Date: Apr 13 2026\r\n" +
+ "SoftDevice: S140 6.1.1\r\n"
+
+ /**
+ * Verbatim `INFO_UF2.TXT` from a RAK4631 on a stock 0.4.3 bootloader (May 2023), captured 2026-07-30.
+ *
+ * Two things this vintage proves: the `SoftDevice:` line goes back at least this far, so the bundled-map fallback
+ * is belt-and-braces rather than the common path; and older bootloaders emit an extra `Ver:` line that 0.9.x
+ * dropped, which the line-scanning parser must tolerate.
+ */
+ private val rak4631StockInfo =
+ "UF2 Bootloader 0.4.3\r\n" +
+ "Model: WisBlock RAK4631 Board\r\n" +
+ "Board-ID: WisBlock-RAK4631-Board\r\n" +
+ "Date: May 20 2023\r\n" +
+ "Ver: 0.4.3\r\n" +
+ "SoftDevice: S140 6.1.1\r\n"
+
+ @Test
+ fun `an old bootloader vintage still reports its softdevice and board id`() {
+ assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice(rak4631StockInfo))
+ assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(rak4631StockInfo))
+ }
+
+ @Test
+ fun `board id is stable across bootloader vintages for the same board`() {
+ // A stock 0.4.3 RAK and an OTAFIX 2.2 RAK report the same Board-ID, so the OTAFIX veto resolves correctly on a
+ // device that has never been upgraded β the case that decides whether the upgrade is offerable at all.
+ assertEquals(parseUf2BoardId(rak4631StockInfo), parseUf2BoardId(rak4631OtafixInfo))
+ }
+
+ @Test
+ fun `both variants are parsed from real captured bootloader payloads`() {
+ // The two sides of the split, each from hardware: an OTAFIX RAK4631 and a stock Seeed L1.
+ assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice(rak4631OtafixInfo))
+ assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice(seeedL1Info))
+
+ assertEquals("WisBlock-RAK4631-Board", parseUf2BoardId(rak4631OtafixInfo))
+ assertNotNull(
+ otafixUf2ForBoardId(parseUf2BoardId(rak4631OtafixInfo)!!),
+ "An OTAFIX-flashed device must still resolve its own image, so re-running the upgrade is idempotent",
+ )
+ }
+
+ @Test
+ fun `softdevice line is parsed for both shipped variants`() {
+ assertEquals(SoftDeviceVariant.S140_6_1_1, parseUf2SoftDevice("SoftDevice: S140 6.1.1\r\n"))
+ assertEquals(SoftDeviceVariant.S140_7_3_0, parseUf2SoftDevice("SoftDevice: S140 7.3.0\r\n"))
+ }
+
+ @Test
+ fun `each erase image targets the app start of the softdevice it is linked for`() {
+ // Verified against hardware 2026-07-30: on a 6.1.1 RAK4631 the app vector table sits at 0x26000
+ // (sp=0x20040000, top of nRF52840 RAM), and 0x27000 is mid-application. On a 7.3.0 device the app starts at
+ // 0x27000, which makes 0x26000 the SoftDevice's last page β the direction that corrupts.
+ assertEquals(0x26000L, APP_START_S140_6_1_1)
+ assertEquals(0x27000L, APP_START_S140_7_3_0)
+ assertEquals(APP_START_S140_6_1_1, eraseUf2ForVariant(SoftDeviceVariant.S140_6_1_1).expectedFirstTargetAddress)
+ assertEquals(APP_START_S140_7_3_0, eraseUf2ForVariant(SoftDeviceVariant.S140_7_3_0).expectedFirstTargetAddress)
+ }
+
+ @Test
+ fun `unsupported softdevice ids and absent lines yield null`() {
+ assertNull(parseUf2SoftDevice("SoftDevice: S132 7.3.0\r\n"), "S132 is not an nRF52840 SoftDevice")
+ assertNull(parseUf2SoftDevice("SoftDevice: S140 9.9.9\r\n"), "No erase image exists for an unknown version")
+ assertNull(parseUf2SoftDevice("SoftDevice: \r\n"), "The bootloader omits the value when no SD is installed")
+ assertNull(parseUf2SoftDevice("Board-ID: TRACKER L1\r\n"), "Absent line")
+ }
+
+ @Test
+ fun `the drive outranks the bundled map`() {
+ // The map says 6.1.1, the device says 7.3.0 with nothing else to go on β but they disagree, so refuse.
+ val conflict =
+ resolveNrfEraseImage(
+ mapped = SoftDeviceVariant.S140_6_1_1,
+ reportedFromDrive = SoftDeviceVariant.S140_7_3_0,
+ )
+ assertTrue(conflict is EraseImageResolution.Conflict, "Disagreement must refuse, not pick a side")
+ }
+
+ @Test
+ fun `agreement resolves to the reported variant's image`() {
+ val resolved =
+ resolveNrfEraseImage(
+ mapped = SoftDeviceVariant.S140_7_3_0,
+ reportedFromDrive = SoftDeviceVariant.S140_7_3_0,
+ )
+
+ assertTrue(resolved is EraseImageResolution.Resolved)
+ assertEquals("nrf_erase_sd7_3.uf2", resolved.asset.fileName)
+ assertEquals(APP_START_S140_7_3_0, resolved.asset.expectedFirstTargetAddress)
+ }
+
+ @Test
+ fun `an old bootloader with no softdevice line falls back to the bundled map`() {
+ val resolved = resolveNrfEraseImage(mapped = SoftDeviceVariant.S140_6_1_1, reportedFromDrive = null)
+
+ assertTrue(resolved is EraseImageResolution.Resolved)
+ assertEquals("nrf_erase2.uf2", resolved.asset.fileName)
+ }
+
+ @Test
+ fun `a drive report rescues an unmapped model`() {
+ // THINKNODE_M8 has no firmware variant on master and so no map row; the drive can still answer.
+ val resolved = resolveNrfEraseImage(mapped = null, reportedFromDrive = SoftDeviceVariant.S140_6_1_1)
+
+ assertTrue(resolved is EraseImageResolution.Resolved)
+ assertEquals(SoftDeviceVariant.S140_6_1_1, resolved.variant)
+ }
+
+ @Test
+ fun `neither source resolving means unresolved`() {
+ assertEquals(EraseImageResolution.Unresolved, resolveNrfEraseImage(mapped = null, reportedFromDrive = null))
+ }
+
+ // ββ UF2 header parsing (R8) ββββββββββββββββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `uf2 target address is read from the first block`() {
+ val block = ByteArray(UF2_BLOCK_BYTES)
+ listOf(0x55, 0x46, 0x32, 0x0A).forEachIndexed { i, b -> block[i] = b.toByte() }
+ block[UF2_TARGET_ADDR_OFFSET] = 0x00
+ block[UF2_TARGET_ADDR_OFFSET + 1] = 0x70
+ block[UF2_TARGET_ADDR_OFFSET + 2] = 0x02
+ block[UF2_TARGET_ADDR_OFFSET + 3] = 0x00
+
+ assertEquals(APP_START_S140_7_3_0, uf2FirstTargetAddress(block))
+ }
+
+ @Test
+ fun `non-uf2 payloads yield no target address`() {
+ assertNull(uf2FirstTargetAddress(ByteArray(UF2_BLOCK_BYTES)), "Zeroed bytes carry no UF2 magic")
+ assertNull(uf2FirstTargetAddress(ByteArray(16)), "A short payload cannot hold a UF2 block")
+ }
+}
diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt
index f76f2f0b2c..9fde2a29b4 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/ota/dfu/DfuZipParserTest.kt
@@ -51,6 +51,61 @@ class DfuZipParserTest {
assertTrue(packageResult.initPacket.contentEquals(byteArrayOf(0x04, 0x05)))
}
+ @Test
+ fun failsWhenManifestDeclaresOnlySoftDeviceBootloader() {
+ // An OTAFIX bootloader package. imageCount == 1, so it slipped past the multi-image warning and was uploaded
+ // with START_DFU's image type hard-coded to APPLICATION and the sd/bl sizes zeroed.
+ val manifestJson =
+ """
+ {
+ "manifest": {
+ "softdevice_bootloader": {
+ "bin_file": "sd_bl.bin",
+ "dat_file": "sd_bl.dat",
+ "bl_size": 39000,
+ "sd_size": 151016
+ }
+ }
+ }
+ """
+ .trimIndent()
+
+ val entries =
+ mapOf(
+ "manifest.json" to manifestJson.encodeToByteArray(),
+ "sd_bl.bin" to byteArrayOf(0x01),
+ "sd_bl.dat" to byteArrayOf(0x02),
+ )
+
+ val ex = assertFailsWith<DfuException.InvalidPackage> { parseDfuZipEntries(entries) }
+ assertTrue(
+ ex.message?.contains("no 'application' image") == true,
+ "Rejection should name the missing application image, was: ${ex.message}",
+ )
+ assertTrue(ex.message?.contains("sd_bl.bin") == true, "Rejection should name what was found instead")
+ }
+
+ @Test
+ fun failsWhenManifestDeclaresOnlyBootloader() {
+ val manifestJson =
+ """
+ {
+ "manifest": {
+ "bootloader": {
+ "bin_file": "bl.bin",
+ "dat_file": "bl.dat"
+ }
+ }
+ }
+ """
+ .trimIndent()
+
+ val entries = mapOf("manifest.json" to manifestJson.encodeToByteArray())
+
+ val ex = assertFailsWith<DfuException.InvalidPackage> { parseDfuZipEntries(entries) }
+ assertTrue(ex.message?.contains("no 'application' image") == true)
+ }
+
@Test
fun failsWhenManifestIsMissing() {
val entries = mapOf("app.bin" to byteArrayOf(), "app.dat" to byteArrayOf())
diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt
index caca9641ba..d8fa9a07ff 100644
--- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt
+++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareUsbManager.kt
@@ -23,4 +23,15 @@ import org.koin.core.annotation.Single
@Single
class DesktopFirmwareUsbManager : FirmwareUsbManager {
override fun deviceDetachFlow(): Flow<Unit> = emptyFlow()
+
+ /**
+ * Desktop has no UF2 maintenance flow β there is no Storage Access Framework to vet a volume with, so
+ * `isRemovableDestination` refuses before anything reaches here.
+ */
+ override suspend fun serialPortKeys(): Set<String> = emptySet()
+
+ override suspend fun unblockCdcPort(excluding: Set<String>, waitMillis: Long, holdMillis: Long): Boolean = false
+
+ /** Desktop serial access has no permission model; there is nothing to preflight. */
+ override suspend fun ensureSerialPermission(waitMillis: Long): Boolean = true
}
diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt
index 62f1d2bab5..37a552606f 100644
--- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt
+++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt
@@ -26,6 +26,7 @@ import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.ioDispatcher
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileOutputStream
@@ -174,6 +175,40 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl
destinationFile.length()
}
+ /**
+ * Always false on desktop: there is no Storage Access Framework to classify a volume with, and the multi-pass UF2
+ * maintenance flow is Android-only. Refusing is the fail-closed answer, consistent with [DesktopFirmwareUsbManager]
+ * reporting the CDC unblock unsupported.
+ */
+ override suspend fun isRemovableDestination(destinationUri: CommonUri): Boolean = false
+
+ override suspend fun isDestinationReadable(destinationUri: CommonUri): Boolean =
+ withContext(ioDispatcher) { destinationUri.toLocalFileOrNull()?.canRead() == true }
+
+ /**
+ * Directory-based equivalents of the Android tree operations. Reachable only if a desktop maintenance flow is ever
+ * built β [isRemovableDestination] refuses first today β but implemented rather than stubbed so the behaviour is
+ * obvious to whoever gets there.
+ */
+ override suspend fun readSiblingText(treeUri: CommonUri, fileName: String): String? = withContext(ioDispatcher) {
+ val dir = treeUri.toLocalFileOrNull() ?: return@withContext null
+ dir.listFiles()
+ ?.firstOrNull { it.name.equals(fileName, ignoreCase = true) }
+ ?.let { file -> safeCatching { file.readText() }.getOrNull() }
+ }
+
+ override suspend fun createDocumentInTree(treeUri: CommonUri, fileName: String, mimeType: String): CommonUri? =
+ withContext(ioDispatcher) {
+ val dir = treeUri.toLocalFileOrNull() ?: return@withContext null
+ safeCatching {
+ dir.mkdirs()
+ val target = File(dir, fileName)
+ target.createNewFile()
+ CommonUri.parse(target.toURI().toString())
+ }
+ .getOrNull()
+ }
+
@Suppress("NestedBlockDepth", "ReturnCount")
private fun extractFromZipFile(
zipFile: File,
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index 00450afd12..e80ea0a023 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -25,6 +25,7 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
import dev.mokkery.verify.VerifyMode.Companion.atLeast
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
@@ -39,14 +40,17 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.common.state.FirmwareMaintenanceLock
import org.meshtastic.core.common.state.HiddenFeaturesUnlock
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.datastore.FirmwareRecoveryDataSource
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.SoftDeviceVariant
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
+import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.resources.Res
@@ -55,6 +59,7 @@ import org.meshtastic.core.resources.firmware_update_extracting
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.core.testing.runUntilSettled
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -84,6 +89,8 @@ class FirmwareUpdateViewModelFileTest {
private val firmwareUpdateManager: FirmwareUpdateManager = mock(MockMode.autofill)
private val usbManager: FirmwareUsbManager = mock(MockMode.autofill)
private val fileHandler: FirmwareFileHandler = mock(MockMode.autofill)
+ private val firmwareRetriever: FirmwareRetriever = mock(MockMode.autofill)
+ private val firmwareMaintenanceLock = FirmwareMaintenanceLock()
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private lateinit var viewModel: FirmwareUpdateViewModel
@@ -138,9 +145,12 @@ class FirmwareUpdateViewModelFileTest {
firmwareUpdateManager,
usbManager,
fileHandler,
+ firmwareRetriever,
+ firmwareMaintenanceLock,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
analytics,
+ NodeRestartTracker(TestApplicationCoroutineScope(testDispatcher)),
)
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
@@ -214,7 +224,12 @@ class FirmwareUpdateViewModelFileTest {
verify {
analytics.trackAction(
"firmware_update_start",
- mapOf("update_method" to "usb", "is_recovery" to false, "release_version" to "local"),
+ mapOf(
+ "update_method" to "usb",
+ "is_recovery" to false,
+ "release_version" to "local",
+ "wipe_device" to false,
+ ),
)
}
}
@@ -752,4 +767,239 @@ class FirmwareUpdateViewModelFileTest {
assertNull(viewModel.pendingLocalFirmwareFile.value)
verifySuspend(exactly(0)) { firmwareUpdateManager.startUpdate(any(), any(), any(), any(), any()) }
}
+
+ // ββ USB maintenance gating (factory erase / bootloader upgrade) ββββββββββββββββββββββββββββββββ
+
+ @Test
+ fun `maintenance is offered for an nrf device over usb with a resolved softdevice`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1))
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val ready = assertIs<FirmwareUpdateState.Ready>(viewModel.state.value)
+ assertTrue(ready.maintenance.show, "nRF over USB should offer maintenance")
+ assertEquals(null, ready.maintenance.eraseRefusal, "a resolved SoftDevice must not refuse")
+ }
+
+ @Test
+ fun `maintenance refuses erase when the softdevice is unresolved`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(softDevice = null))
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val ready = assertIs<FirmwareUpdateState.Ready>(viewModel.state.value)
+ assertTrue(ready.maintenance.show, "the action stays visible so the refusal can be explained")
+ assertEquals(UsbMaintenanceRefusal.UnknownSoftDevice, ready.maintenance.eraseRefusal)
+ }
+
+ @Test
+ fun `maintenance is hidden over bluetooth`() = runTest {
+ // The flow needs the UF2 mass-storage volume, which only exists on a USB connection.
+ every { radioPrefs.devAddr } returns MutableStateFlow("x11:22:33:44:55:66")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1))
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val ready = assertIs<FirmwareUpdateState.Ready>(viewModel.state.value)
+ assertFalse(ready.maintenance.show)
+ }
+
+ @Test
+ fun `starting a refused erase performs no reboot and no download`() = runTest {
+ // Defence in depth behind the disabled button: a refused erase must not touch the device.
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(softDevice = null))
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ assertIs<FirmwareUpdateState.Ready>(viewModel.state.value)
+
+ viewModel.startUpdate(wipeDevice = true)
+ advanceUntilIdle()
+
+ assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
+ // performUsbMaintenance downloads the firmware before it reboots to DFU, so proving no download was attempted
+ // also proves the device was never rebooted. FakeRadioController.rebootToDfu records nothing to assert on.
+ verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) }
+ }
+
+ @Test
+ fun `starting a bootloader upgrade the gate would not offer performs no reboot and no download`() = runTest {
+ // Defence in depth: an ESP32 has no bootloader-upgrade action to show in the first place
+ // (showBootloaderUpgrade is nRF-only), so a stray call must refuse before touching the device.
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(DeviceHardware(hwModel = 1, architecture = "esp32", platformioTarget = "tbeam"))
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ val ready = assertIs<FirmwareUpdateState.Ready>(viewModel.state.value)
+ assertFalse(ready.maintenance.showBootloaderUpgrade)
+
+ viewModel.startBootloaderUpgrade()
+ advanceUntilIdle()
+
+ assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
+ verifySuspend(mode = VerifyMode.not) { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) }
+ assertFalse(firmwareMaintenanceLock.isActive, "a refused request must never take the maintenance lock")
+ }
+
+ @Test
+ fun `writeMaintenancePass is ignored when no sequence is running`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ val before = viewModel.state.value
+
+ viewModel.writeMaintenancePass(CommonUri.parse("content://tree/1234-5678%3A"))
+ advanceUntilIdle()
+
+ assertEquals(before, viewModel.state.value, "a stray volume pick must not change state")
+ }
+
+ private fun nrfHardware(softDevice: SoftDeviceVariant?) = DeviceHardware(
+ hwModel = 9,
+ hwModelSlug = "RAK4631",
+ platformioTarget = "rak4631",
+ architecture = "nrf52840",
+ displayName = "RAK4631",
+ activelySupported = true,
+ softDeviceVariant = softDevice,
+ )
+
+ @Test
+ fun `a refused erase never takes the maintenance lock`() = runTest {
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(softDevice = null))
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.startUpdate(wipeDevice = true)
+ advanceUntilIdle()
+
+ assertFalse(
+ firmwareMaintenanceLock.isActive,
+ "refusing before the sequence starts must leave the radio transport unblocked",
+ )
+ }
+
+ @Test
+ fun `a failed preparation releases the maintenance lock`() = runTest {
+ // The lock suppresses transport restarts; leaking it would leave the app unable to reconnect at all.
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1))
+ everySuspend { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } returns null
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.startUpdate(wipeDevice = true)
+ // CMP 1.12 loads string resources on an internal Dispatchers.Default scope, outside the
+ // test scheduler β drain in real time until the maintenance flow reaches its terminal state.
+ runUntilSettled { viewModel.state.value is FirmwareUpdateState.Error }
+
+ assertIs<FirmwareUpdateState.Error>(viewModel.state.value)
+ assertFalse(firmwareMaintenanceLock.isActive, "a failed preparation must not leak the lock")
+ }
+
+ @Test
+ fun `completing the firmware pass of a maintenance sequence releases the lock`() = runTest {
+ // Regression: the FromVolume (erase) pass releases the lock through advancePastPass, but the sequence's
+ // final Prepared (firmware) pass is saved through the pre-existing saveDfuFile β which had no idea the lock
+ // existed. That left every SUCCESSFUL erase/upgrade leaking the lock forever, permanently suppressing the
+ // radio transport's auto-reconnect for the rest of the app session (see SharedRadioInterfaceService).
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any(), any(), any()) } returns
+ Result.success(nrfHardware(SoftDeviceVariant.S140_6_1_1))
+
+ val firmwareArtifact =
+ FirmwareArtifact(uri = CommonUri.parse("file:///tmp/firmware.uf2"), fileName = "firmware.uf2")
+ val eraseArtifact = FirmwareArtifact(uri = CommonUri.parse("file:///tmp/erase.uf2"), fileName = "erase.uf2")
+ everySuspend { firmwareRetriever.retrieveUsbFirmware(any(), any(), any()) } returns firmwareArtifact
+ everySuspend { firmwareRetriever.retrieveMaintenanceUf2(any(), any()) } returns eraseArtifact
+
+ // No SoftDevice line on the volume β falls back to the (resolved) map variant, matching every other case
+ // this suite already covers for that fallback.
+ everySuspend { fileHandler.isRemovableDestination(any()) } returns true
+ everySuspend { fileHandler.readSiblingText(any(), any()) } returns "Board-ID: Test-Board\r\n"
+ everySuspend { fileHandler.createDocumentInTree(any(), any(), any()) } returns
+ CommonUri.parse("content://tree/1234-5678%3A/document/erase.uf2")
+ everySuspend { fileHandler.copyToUri(any(), any()) } returns 1024L
+ every { usbManager.deviceDetachFlow() } returns flowOf(Unit)
+ everySuspend { usbManager.serialPortKeys() } returns emptySet()
+ everySuspend { usbManager.unblockCdcPort(any(), any(), any()) } returns true
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+ viewModel.startUpdate(wipeDevice = true)
+ // CMP 1.12 loads string resources outside the test scheduler; settle in real time per pass.
+ runUntilSettled { viewModel.state.value is FirmwareUpdateState.AwaitingFileSave }
+
+ // First pass (erase image) β writes through writeMaintenancePass.
+ val awaitingErase = assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
+ assertEquals(UsbFileSaveStep.FactoryErase, awaitingErase.step)
+ viewModel.writeMaintenancePass(CommonUri.parse("content://tree/1234-5678%3A"))
+ runUntilSettled {
+ (viewModel.state.value as? FirmwareUpdateState.AwaitingFileSave)?.step == UsbFileSaveStep.Firmware
+ }
+
+ assertTrue(firmwareMaintenanceLock.isActive, "the lock must still be held between passes")
+ val awaitingFirmware = assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
+ assertEquals(UsbFileSaveStep.Firmware, awaitingFirmware.step)
+ assertNotNull(awaitingFirmware.uf2Artifact, "the terminal pass must carry its artifact")
+
+ // Second, terminal pass (firmware image) β writes through the pre-existing saveDfuFile.
+ viewModel.saveDfuFile(CommonUri.parse("file:///output/firmware.uf2"))
+ runUntilSettled { !firmwareMaintenanceLock.isActive }
+
+ assertFalse(
+ firmwareMaintenanceLock.isActive,
+ "completing the sequence's terminal pass must release the lock, or auto-reconnect stays suppressed forever",
+ )
+
+ // The rebooted device is a new USB identity to Android, so verification must preflight the permission
+ // grant β otherwise auto-recovery dies on SecurityException and a healthy update reads as a failure.
+ advanceUntilIdle()
+ verifySuspend { usbManager.ensureSerialPermission(any()) }
+ }
+
+ @Test
+ fun `a granted USB permission preflight reconnects explicitly instead of waiting on auto-recovery`() = runTest {
+ // Auto-recovery's attach trigger fires while the permission dialog is still up and never retries on
+ // the grant, so verification must reconnect deterministically once the grant is in hand.
+ every { radioPrefs.devAddr } returns MutableStateFlow("s/dev/ttyUSB0")
+ everySuspend { usbManager.ensureSerialPermission(any()) } returns true
+ every { usbManager.deviceDetachFlow() } returns flowOf(Unit)
+ everySuspend { fileHandler.copyToUri(any(), any()) } returns 1024L
+
+ viewModel = createViewModel()
+ advanceUntilIdle()
+
+ val artifact = FirmwareArtifact(uri = CommonUri.parse("file:///tmp/firmware.uf2"), fileName = "firmware.uf2")
+ everySuspend { firmwareUpdateManager.startUpdate(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ val updateState = it.args[3] as (FirmwareUpdateState) -> Unit
+ updateState(FirmwareUpdateState.AwaitingFileSave(uf2Artifact = artifact, fileName = "firmware.uf2"))
+ artifact
+ }
+ viewModel.startUpdate()
+ advanceUntilIdle()
+ assertIs<FirmwareUpdateState.AwaitingFileSave>(viewModel.state.value)
+
+ viewModel.saveDfuFile(CommonUri.parse("file:///output/firmware.uf2"))
+ advanceUntilIdle()
+
+ assertEquals("s/dev/ttyUSB0", radioController.lastSetDeviceAddress, "grant in hand β explicit reconnect")
+ }
}
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt
new file mode 100644
index 0000000000..9017953563
--- /dev/null
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/MaintenanceVolumeTest.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+/**
+ * JVM runner for [CommonMaintenanceVolumeTest].
+ *
+ * The base class is abstract because it builds `CommonUri`. Without a concrete subclass it would contribute zero tests
+ * silently β see PR #6501, which revived eight bases that had been inert for exactly this reason.
+ */
+class MaintenanceVolumeTest : CommonMaintenanceVolumeTest()
Served by rngit 1.5.2 - Generated in 0.41s